diff --git a/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml b/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml index 05606dc6d574..36cfc8b66070 100644 --- a/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml +++ b/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml @@ -345,6 +345,7 @@ + diff --git a/codegen/src/main/java/software/amazon/awssdk/codegen/poet/client/ClientClassUtils.java b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/client/ClientClassUtils.java index 9684be34b02e..353593d6d4b6 100644 --- a/codegen/src/main/java/software/amazon/awssdk/codegen/poet/client/ClientClassUtils.java +++ b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/client/ClientClassUtils.java @@ -564,8 +564,7 @@ static MethodSpec resolveEndpointMethod(AuthSchemeSpecUtils authSchemeSpecUtils, authSchemeOption.nestedClass("Builder")); b.addStatement("$1T rs = $1T.create(endpointParams.region().id())", regionSet); b.addStatement("optionBuilder.putSignerProperty($T.REGION_SET, rs)", awsV4aHttpSigner); - b.addStatement("selectedAuthScheme = new $T(selectedAuthScheme.identity(), selectedAuthScheme.signer(), " - + "optionBuilder.build())", SelectedAuthScheme.class); + b.addStatement("selectedAuthScheme = selectedAuthScheme.toBuilder().authSchemeOption(optionBuilder.build()).build()"); b.endControlFlow(); } diff --git a/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules/EndpointResolverUtilsSpec.java b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules/EndpointResolverUtilsSpec.java index 84677fcae0cd..013a6a54d602 100644 --- a/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules/EndpointResolverUtilsSpec.java +++ b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules/EndpointResolverUtilsSpec.java @@ -602,7 +602,9 @@ private static CodeBlock copyV4EndpointSignerPropertiesToAuth() { code.addStatement("option.putSignerProperty($T.SERVICE_SIGNING_NAME, v4AuthScheme.signingName())", AwsV4HttpSigner.class); code.endControlFlow(); - code.addStatement("return new $T<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build())", + code.addStatement("return $T.builder().identity(selectedAuthScheme.identity())" + + ".signer(selectedAuthScheme.signer()).authSchemeOption(option.build())" + + ".identityProvider(selectedAuthScheme.identityProvider()).build()", SelectedAuthScheme.class); code.endControlFlow(); return code.build(); @@ -631,7 +633,9 @@ private CodeBlock copyV4aEndpointSignerPropertiesToAuth() { code.addStatement("option.putSignerProperty($T.SERVICE_SIGNING_NAME, v4aAuthScheme.signingName())", AwsV4aHttpSigner.class); code.endControlFlow(); - code.addStatement("return new $T<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build())", + code.addStatement("return $T.builder().identity(selectedAuthScheme.identity())" + + ".signer(selectedAuthScheme.signer()).authSchemeOption(option.build())" + + ".identityProvider(selectedAuthScheme.identityProvider()).build()", SelectedAuthScheme.class); code.endControlFlow(); return code.build(); @@ -656,7 +660,9 @@ private CodeBlock copyS3ExpressEndpointSignerPropertiesToAuth() { code.addStatement("option.putSignerProperty($T.SERVICE_SIGNING_NAME, s3ExpressAuthScheme.signingName())", AwsV4HttpSigner.class); code.endControlFlow(); - code.addStatement("return new $T<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build())", + code.addStatement("return $T.builder().identity(selectedAuthScheme.identity())" + + ".signer(selectedAuthScheme.signer()).authSchemeOption(option.build())" + + ".identityProvider(selectedAuthScheme.identityProvider()).build()", SelectedAuthScheme.class); code.endControlFlow(); return code.build(); diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/client/test-unsigned-payload-trait-async-client-class.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/client/test-unsigned-payload-trait-async-client-class.java index 3e76e9b15269..4d224bbe73a3 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/client/test-unsigned-payload-trait-async-client-class.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/client/test-unsigned-payload-trait-async-client-class.java @@ -1080,8 +1080,7 @@ private Endpoint resolveEndpoint(SdkRequest request, ExecutionAttributes executi AuthSchemeOption.Builder optionBuilder = selectedAuthScheme.authSchemeOption().toBuilder(); RegionSet rs = RegionSet.create(endpointParams.region().id()); optionBuilder.putSignerProperty(AwsV4aHttpSigner.REGION_SET, rs); - selectedAuthScheme = new SelectedAuthScheme(selectedAuthScheme.identity(), selectedAuthScheme.signer(), - optionBuilder.build()); + selectedAuthScheme = selectedAuthScheme.toBuilder().authSchemeOption(optionBuilder.build()).build(); } executionAttributes.putAttribute(SdkInternalExecutionAttribute.SELECTED_AUTH_SCHEME, selectedAuthScheme); } diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/client/test-unsigned-payload-trait-sync-client-class.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/client/test-unsigned-payload-trait-sync-client-class.java index 4d7ce7f0f45c..1050590421f9 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/client/test-unsigned-payload-trait-sync-client-class.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/client/test-unsigned-payload-trait-sync-client-class.java @@ -965,8 +965,7 @@ private Endpoint resolveEndpoint(SdkRequest request, ExecutionAttributes executi AuthSchemeOption.Builder optionBuilder = selectedAuthScheme.authSchemeOption().toBuilder(); RegionSet rs = RegionSet.create(endpointParams.region().id()); optionBuilder.putSignerProperty(AwsV4aHttpSigner.REGION_SET, rs); - selectedAuthScheme = new SelectedAuthScheme(selectedAuthScheme.identity(), selectedAuthScheme.signer(), - optionBuilder.build()); + selectedAuthScheme = selectedAuthScheme.toBuilder().authSchemeOption(optionBuilder.build()).build(); } executionAttributes.putAttribute(SdkInternalExecutionAttribute.SELECTED_AUTH_SCHEME, selectedAuthScheme); } diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-preSra.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-preSra.java index cf27b03ed0c3..792febf9c653 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-preSra.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-preSra.java @@ -177,7 +177,7 @@ private SelectedAuthScheme authSchemeWithEndpointSignerP if (v4AuthScheme.signingName() != null) { option.putSignerProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, v4AuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } if (endpointAuthScheme instanceof SigV4aAuthScheme) { SigV4aAuthScheme v4aAuthScheme = (SigV4aAuthScheme) endpointAuthScheme; @@ -191,7 +191,7 @@ private SelectedAuthScheme authSchemeWithEndpointSignerP if (v4aAuthScheme.signingName() != null) { option.putSignerProperty(AwsV4aHttpSigner.SERVICE_SIGNING_NAME, v4aAuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } throw new IllegalArgumentException("Endpoint auth scheme '" + endpointAuthScheme.name() + "' cannot be mapped to the SDK auth scheme. Was it declared in the service's model?"); diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-endpointsbasedauth.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-endpointsbasedauth.java index 6d6aa1dc2138..944d03995440 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-endpointsbasedauth.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-endpointsbasedauth.java @@ -81,8 +81,7 @@ public SdkRequest modifyRequest(Context.ModifyRequest context, ExecutionAttribut AuthSchemeOption.Builder optionBuilder = selectedAuthScheme.authSchemeOption().toBuilder(); RegionSet regionSet = RegionSet.create(endpointParams.region().id()); optionBuilder.putSignerProperty(AwsV4aHttpSigner.REGION_SET, regionSet); - selectedAuthScheme = new SelectedAuthScheme(selectedAuthScheme.identity(), selectedAuthScheme.signer(), - optionBuilder.build()); + selectedAuthScheme = selectedAuthScheme.toBuilder().authSchemeOption(optionBuilder.build()).build(); } executionAttributes.putAttribute(SdkInternalExecutionAttribute.SELECTED_AUTH_SCHEME, selectedAuthScheme); } @@ -172,7 +171,7 @@ private SelectedAuthScheme authSchemeWithEndpointSignerP if (v4AuthScheme.signingName() != null) { option.putSignerProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, v4AuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } if (endpointAuthScheme instanceof SigV4aAuthScheme) { SigV4aAuthScheme v4aAuthScheme = (SigV4aAuthScheme) endpointAuthScheme; @@ -188,7 +187,7 @@ private SelectedAuthScheme authSchemeWithEndpointSignerP if (v4aAuthScheme.signingName() != null) { option.putSignerProperty(AwsV4aHttpSigner.SERVICE_SIGNING_NAME, v4aAuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } throw new IllegalArgumentException("Endpoint auth scheme '" + endpointAuthScheme.name() + "' cannot be mapped to the SDK auth scheme. Was it declared in the service's model?"); diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-multiauthsigv4a.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-multiauthsigv4a.java index 858d39fc7a69..5faf70df4a08 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-multiauthsigv4a.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-multiauthsigv4a.java @@ -70,8 +70,7 @@ public SdkRequest modifyRequest(Context.ModifyRequest context, ExecutionAttribut AuthSchemeOption.Builder optionBuilder = selectedAuthScheme.authSchemeOption().toBuilder(); RegionSet regionSet = RegionSet.create(endpointParams.region().id()); optionBuilder.putSignerProperty(AwsV4aHttpSigner.REGION_SET, regionSet); - selectedAuthScheme = new SelectedAuthScheme(selectedAuthScheme.identity(), selectedAuthScheme.signer(), - optionBuilder.build()); + selectedAuthScheme = selectedAuthScheme.toBuilder().authSchemeOption(optionBuilder.build()).build(); } executionAttributes.putAttribute(SdkInternalExecutionAttribute.SELECTED_AUTH_SCHEME, selectedAuthScheme); } @@ -135,7 +134,7 @@ private SelectedAuthScheme authSchemeWithEndpointSignerP if (v4AuthScheme.signingName() != null) { option.putSignerProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, v4AuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } if (endpointAuthScheme instanceof SigV4aAuthScheme) { SigV4aAuthScheme v4aAuthScheme = (SigV4aAuthScheme) endpointAuthScheme; @@ -151,7 +150,7 @@ private SelectedAuthScheme authSchemeWithEndpointSignerP if (v4aAuthScheme.signingName() != null) { option.putSignerProperty(AwsV4aHttpSigner.SERVICE_SIGNING_NAME, v4aAuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } throw new IllegalArgumentException("Endpoint auth scheme '" + endpointAuthScheme.name() + "' cannot be mapped to the SDK auth scheme. Was it declared in the service's model?"); diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-stringarray.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-stringarray.java index 0f5033376ffc..b86e695868ce 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-stringarray.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-stringarray.java @@ -144,7 +144,7 @@ private SelectedAuthScheme authSchemeWithEndpointSignerP if (v4AuthScheme.signingName() != null) { option.putSignerProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, v4AuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } if (endpointAuthScheme instanceof SigV4aAuthScheme) { SigV4aAuthScheme v4aAuthScheme = (SigV4aAuthScheme) endpointAuthScheme; @@ -158,7 +158,7 @@ private SelectedAuthScheme authSchemeWithEndpointSignerP if (v4aAuthScheme.signingName() != null) { option.putSignerProperty(AwsV4aHttpSigner.SERVICE_SIGNING_NAME, v4aAuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } throw new IllegalArgumentException("Endpoint auth scheme '" + endpointAuthScheme.name() + "' cannot be mapped to the SDK auth scheme. Was it declared in the service's model?"); diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor.java index abdfedab0455..59e6af3b36f5 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor.java @@ -163,7 +163,7 @@ private SelectedAuthScheme authSchemeWithEndpointSignerP if (v4AuthScheme.signingName() != null) { option.putSignerProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, v4AuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } if (endpointAuthScheme instanceof SigV4aAuthScheme) { SigV4aAuthScheme v4aAuthScheme = (SigV4aAuthScheme) endpointAuthScheme; @@ -177,7 +177,7 @@ private SelectedAuthScheme authSchemeWithEndpointSignerP if (v4aAuthScheme.signingName() != null) { option.putSignerProperty(AwsV4aHttpSigner.SERVICE_SIGNING_NAME, v4aAuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } throw new IllegalArgumentException("Endpoint auth scheme '" + endpointAuthScheme.name() + "' cannot be mapped to the SDK auth scheme. Was it declared in the service's model?"); diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils-with-endpointsbasedauth.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils-with-endpointsbasedauth.java index ad3b2e674750..5197385b7c28 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils-with-endpointsbasedauth.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils-with-endpointsbasedauth.java @@ -101,7 +101,7 @@ public static SelectedAuthScheme authSchemeWithEndpointS if (v4AuthScheme.signingName() != null) { option.putSignerProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, v4AuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } if (endpointAuthScheme instanceof SigV4aAuthScheme) { SigV4aAuthScheme v4aAuthScheme = (SigV4aAuthScheme) endpointAuthScheme; @@ -117,7 +117,7 @@ public static SelectedAuthScheme authSchemeWithEndpointS if (v4aAuthScheme.signingName() != null) { option.putSignerProperty(AwsV4aHttpSigner.SERVICE_SIGNING_NAME, v4aAuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } throw new IllegalArgumentException("Endpoint auth scheme '" + endpointAuthScheme.name() + "' cannot be mapped to the SDK auth scheme. Was it declared in the service's model?"); diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils-with-multiauthsigv4a.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils-with-multiauthsigv4a.java index f4a83932a826..1030af0199ca 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils-with-multiauthsigv4a.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils-with-multiauthsigv4a.java @@ -64,7 +64,7 @@ public static SelectedAuthScheme authSchemeWithEndpointS if (v4AuthScheme.signingName() != null) { option.putSignerProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, v4AuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } if (endpointAuthScheme instanceof SigV4aAuthScheme) { SigV4aAuthScheme v4aAuthScheme = (SigV4aAuthScheme) endpointAuthScheme; @@ -80,7 +80,7 @@ public static SelectedAuthScheme authSchemeWithEndpointS if (v4aAuthScheme.signingName() != null) { option.putSignerProperty(AwsV4aHttpSigner.SERVICE_SIGNING_NAME, v4aAuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } throw new IllegalArgumentException("Endpoint auth scheme '" + endpointAuthScheme.name() + "' cannot be mapped to the SDK auth scheme. Was it declared in the service's model?"); diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils-with-stringarray.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils-with-stringarray.java index aaed43624739..e85d33c4b7dc 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils-with-stringarray.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils-with-stringarray.java @@ -83,7 +83,7 @@ public static SelectedAuthScheme authSchemeWithEndpointS if (v4AuthScheme.signingName() != null) { option.putSignerProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, v4AuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } if (endpointAuthScheme instanceof SigV4aAuthScheme) { SigV4aAuthScheme v4aAuthScheme = (SigV4aAuthScheme) endpointAuthScheme; @@ -97,7 +97,7 @@ public static SelectedAuthScheme authSchemeWithEndpointS if (v4aAuthScheme.signingName() != null) { option.putSignerProperty(AwsV4aHttpSigner.SERVICE_SIGNING_NAME, v4aAuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } throw new IllegalArgumentException("Endpoint auth scheme '" + endpointAuthScheme.name() + "' cannot be mapped to the SDK auth scheme. Was it declared in the service's model?"); } diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils.java index 01c7ba43831f..f213bf77f076 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils.java @@ -101,7 +101,7 @@ public static SelectedAuthScheme authSchemeWithEndpointS if (v4AuthScheme.signingName() != null) { option.putSignerProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, v4AuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } if (endpointAuthScheme instanceof SigV4aAuthScheme) { SigV4aAuthScheme v4aAuthScheme = (SigV4aAuthScheme) endpointAuthScheme; @@ -115,7 +115,7 @@ public static SelectedAuthScheme authSchemeWithEndpointS if (v4aAuthScheme.signingName() != null) { option.putSignerProperty(AwsV4aHttpSigner.SERVICE_SIGNING_NAME, v4aAuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } throw new IllegalArgumentException("Endpoint auth scheme '" + endpointAuthScheme.name() + "' cannot be mapped to the SDK auth scheme. Was it declared in the service's model?"); diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/AwsCredentialsProviderChain.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/AwsCredentialsProviderChain.java index 439fcc987406..f98138cefc57 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/AwsCredentialsProviderChain.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/AwsCredentialsProviderChain.java @@ -20,6 +20,7 @@ import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.concurrent.CompletableFuture; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; @@ -130,6 +131,44 @@ public AwsCredentials resolveCredentials() { .build(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + if (reuseLastProviderEnabled && lastUsedProvider != null) { + return invalidateProvider(lastUsedProvider, identity) + .exceptionally(e -> { + log.debug(() -> "Failed to invalidate provider " + lastUsedProvider + ": " + e.getMessage(), e); + return null; + }); + } + + CompletableFuture[] futures = credentialsProviders.stream() + .map(provider -> { + try { + return invalidateProvider(provider, identity) + .exceptionally(e -> { + log.debug(() -> "Failed to invalidate provider " + provider + ": " + e.getMessage(), e); + return null; + }); + } catch (Exception e) { + log.debug(() -> "Failed to invalidate provider " + provider + ": " + e.getMessage(), e); + return CompletableFuture.completedFuture(null); + } + }) + .toArray(CompletableFuture[]::new); + return CompletableFuture.allOf(futures); + } + + /** + * Helper to call invalidate with proper type capture, avoiding unchecked casts. + * The identity is always an {@code AwsCredentialsIdentity}, and all providers in this chain + * are {@code IdentityProvider}, so the cast is safe. + */ + @SuppressWarnings("unchecked") + private static CompletableFuture invalidateProvider( + IdentityProvider provider, AwsCredentialsIdentity identity) { + return provider.invalidate((T) identity); + } + @Override public void close() { credentialsProviders.forEach(c -> IoUtils.closeIfCloseable(c, null)); diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProvider.java index 94744da5da6c..ff8d1b170467 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProvider.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProvider.java @@ -34,6 +34,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import java.util.function.Predicate; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.auth.credentials.internal.ContainerCredentialsRetryPolicy; @@ -43,6 +44,7 @@ import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; import software.amazon.awssdk.core.util.SdkUserAgent; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.regions.util.ResourcesEndpointProvider; import software.amazon.awssdk.regions.util.ResourcesEndpointRetryPolicy; import software.amazon.awssdk.utils.StringUtils; @@ -186,6 +188,13 @@ public AwsCredentials resolveCredentials() { return credentialsCache.get(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + String rejectedAccessKeyId = identity.accessKeyId(); + credentialsCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId())); + return CompletableFuture.completedFuture(null); + } + @Override public void close() { credentialsCache.close(); diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/DefaultCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/DefaultCredentialsProvider.java index d9059aa0298e..2a2cd6a7b9fa 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/DefaultCredentialsProvider.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/DefaultCredentialsProvider.java @@ -16,9 +16,11 @@ package software.amazon.awssdk.auth.credentials; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.auth.credentials.internal.LazyAwsCredentialsProvider; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.profiles.ProfileFile; import software.amazon.awssdk.profiles.ProfileFileSupplier; import software.amazon.awssdk.utils.SdkAutoCloseable; @@ -134,6 +136,11 @@ public AwsCredentials resolveCredentials() { return providerChain.resolveCredentials(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + return providerChain.invalidate(identity); + } + @Override public void close() { providerChain.close(); diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProvider.java index 713bc6cc1cf1..67c09cceac1b 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProvider.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProvider.java @@ -26,6 +26,7 @@ import java.util.Collections; import java.util.Map; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.annotations.SdkTestInternalApi; @@ -37,6 +38,7 @@ import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.core.exception.SdkServiceException; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.profiles.ProfileFile; import software.amazon.awssdk.profiles.ProfileFileSupplier; import software.amazon.awssdk.profiles.ProfileFileSystemSetting; @@ -167,6 +169,13 @@ public AwsCredentials resolveCredentials() { return credentialsCache.get(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + String rejectedAccessKeyId = identity.accessKeyId(); + credentialsCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId())); + return CompletableFuture.completedFuture(null); + } + private RefreshResult refreshCredentials() { if (isLocalCredentialLoadingDisabled()) { throw SdkClientException.create("IMDS credentials have been disabled by environment variable or system property."); diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProvider.java index 17be7f789d8f..8d764e331834 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProvider.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProvider.java @@ -25,8 +25,10 @@ import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.protocols.jsoncore.JsonNode; import software.amazon.awssdk.protocols.jsoncore.JsonNodeParser; import software.amazon.awssdk.utils.DateUtils; @@ -166,6 +168,13 @@ public AwsCredentials resolveCredentials() { return processCredentialCache.get(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + String rejectedAccessKeyId = identity.accessKeyId(); + processCredentialCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId())); + return CompletableFuture.completedFuture(null); + } + private RefreshResult refreshCredentials() { try { String processOutput = executeCommand(); @@ -363,7 +372,7 @@ public Builder asyncCredentialUpdateEnabled(Boolean asyncCredentialUpdateEnabled *

This value must be less than or equal to {@link #prefetchTime(Duration)}. Setting this equal to * {@code prefetchTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking). * - *

By default, this is 1 minute.

+ *

By default, this is 1 minute. * * @param staleTime the duration before expiration that triggers mandatory (blocking) refresh */ @@ -389,7 +398,7 @@ public Builder staleTime(Duration staleTime) { *

If not explicitly set, the advisory refresh window is computed dynamically based on the credential's * remaining lifetime: 5 minutes for credentials with less than 20 minutes remaining, 15 minutes for 20-90 * minutes remaining, and 60 minutes for 90+ minutes remaining. This dynamic window is recomputed on each - * successful refresh.

+ * successful refresh. * * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh */ diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProfileCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProfileCredentialsProvider.java index f95dafdb36f3..24b838fb9dc1 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProfileCredentialsProvider.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProfileCredentialsProvider.java @@ -17,12 +17,14 @@ import java.util.Objects; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; import java.util.function.Supplier; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.annotations.SdkTestInternalApi; import software.amazon.awssdk.auth.credentials.internal.ProfileCredentialsUtils; import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.profiles.ProfileFile; import software.amazon.awssdk.profiles.ProfileFileSupplier; import software.amazon.awssdk.profiles.ProfileFileSystemSetting; @@ -158,6 +160,16 @@ public void close() { IoUtils.closeIfCloseable(credentialsProvider, null); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + // Local copy to avoid TOCTOU race on the volatile field during concurrent profile reloads. + AwsCredentialsProvider provider = credentialsProvider; + if (provider != null) { + return provider.invalidate(identity); + } + return CompletableFuture.completedFuture(null); + } + @Override public Builder toBuilder() { return new BuilderImpl(this); diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/WebIdentityTokenFileCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/WebIdentityTokenFileCredentialsProvider.java index e108482b0490..0e507e5022db 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/WebIdentityTokenFileCredentialsProvider.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/WebIdentityTokenFileCredentialsProvider.java @@ -20,11 +20,13 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.time.Duration; +import java.util.concurrent.CompletableFuture; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.auth.credentials.internal.WebIdentityCredentialsUtils; import software.amazon.awssdk.auth.credentials.internal.WebIdentityTokenCredentialProperties; import software.amazon.awssdk.core.SdkSystemSetting; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.utils.IoUtils; import software.amazon.awssdk.utils.SdkAutoCloseable; import software.amazon.awssdk.utils.ToString; @@ -163,6 +165,14 @@ public void close() { IoUtils.closeIfCloseable(credentialsProvider, null); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + if (credentialsProvider != null) { + return credentialsProvider.invalidate(identity); + } + return CompletableFuture.completedFuture(null); + } + /** * A builder for creating a custom {@link WebIdentityTokenFileCredentialsProvider}. */ diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/LazyAwsCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/LazyAwsCredentialsProvider.java index 6999e25af250..46068658eb7c 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/LazyAwsCredentialsProvider.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/LazyAwsCredentialsProvider.java @@ -15,10 +15,12 @@ package software.amazon.awssdk.auth.credentials.internal; +import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.auth.credentials.AwsCredentials; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.utils.IoUtils; import software.amazon.awssdk.utils.Lazy; import software.amazon.awssdk.utils.SdkAutoCloseable; @@ -45,6 +47,15 @@ public AwsCredentials resolveCredentials() { return delegate.getValue().resolveCredentials(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + if (delegate.hasValue()) { + return delegate.getValue().invalidate(identity); + } + // If not yet initialized, invalidation is a no-op + return CompletableFuture.completedFuture(null); + } + @Override public void close() { IoUtils.closeIfCloseable(delegate, null); diff --git a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/AwsCredentialsProviderChainTest.java b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/AwsCredentialsProviderChainTest.java index 8a59ad26c15c..5af9e0dca76d 100644 --- a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/AwsCredentialsProviderChainTest.java +++ b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/AwsCredentialsProviderChainTest.java @@ -18,8 +18,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import java.util.Arrays; +import java.util.concurrent.CompletableFuture; import org.junit.Test; import org.junit.jupiter.api.function.Executable; import software.amazon.awssdk.core.exception.SdkClientException; @@ -172,6 +178,106 @@ private static void assertChainResolvesCorrectly(AwsCredentialsProviderChain cha assertThat(credentials.secretAccessKey()).isEqualTo("secretKey"); } + @Test + public void invalidate_reuseEnabled_lastProviderSet_onlyInvalidatesLastProvider() { + TrackingCredentialsProvider provider1 = new TrackingCredentialsProvider("key1", "secret1"); + TrackingCredentialsProvider provider2 = new TrackingCredentialsProvider("key2", "secret2"); + + AwsCredentialsProviderChain chain = AwsCredentialsProviderChain.builder() + .credentialsProviders(provider1, provider2) + .reuseLastProviderEnabled(true) + .build(); + + // Trigger resolveCredentials so lastUsedProvider is set (provider1 succeeds first) + chain.resolveCredentials(); + + AwsCredentialsIdentity identity = AwsBasicCredentials.create("key1", "secret1"); + chain.invalidate(identity).join(); + + assertThat(provider1.invalidateCallCount).isEqualTo(1); + assertThat(provider2.invalidateCallCount).isEqualTo(0); + } + + @Test + public void invalidate_reuseEnabled_lastProviderNotSet_invalidatesAllProviders() { + TrackingCredentialsProvider provider1 = new TrackingCredentialsProvider("key1", "secret1"); + TrackingCredentialsProvider provider2 = new TrackingCredentialsProvider("key2", "secret2"); + + AwsCredentialsProviderChain chain = AwsCredentialsProviderChain.builder() + .credentialsProviders(provider1, provider2) + .reuseLastProviderEnabled(true) + .build(); + + // Do NOT call resolveCredentials — lastUsedProvider is null + AwsCredentialsIdentity identity = AwsBasicCredentials.create("key1", "secret1"); + chain.invalidate(identity).join(); + + assertThat(provider1.invalidateCallCount).isEqualTo(1); + assertThat(provider2.invalidateCallCount).isEqualTo(1); + } + + @Test + public void invalidate_reuseDisabled_invalidatesAllProviders() { + TrackingCredentialsProvider provider1 = new TrackingCredentialsProvider("key1", "secret1"); + TrackingCredentialsProvider provider2 = new TrackingCredentialsProvider("key2", "secret2"); + + AwsCredentialsProviderChain chain = AwsCredentialsProviderChain.builder() + .credentialsProviders(provider1, provider2) + .reuseLastProviderEnabled(false) + .build(); + + // Even after resolving, all providers should be invalidated + chain.resolveCredentials(); + + AwsCredentialsIdentity identity = AwsBasicCredentials.create("key1", "secret1"); + chain.invalidate(identity).join(); + + assertThat(provider1.invalidateCallCount).isEqualTo(1); + assertThat(provider2.invalidateCallCount).isEqualTo(1); + } + + @SuppressWarnings("unchecked") + @Test + public void invalidate_doesNotShortCircuit_whenChildThrows() { + AwsCredentialsProvider mockProvider1 = mock(AwsCredentialsProvider.class); + AwsCredentialsProvider mockProvider2 = mock(AwsCredentialsProvider.class); + AwsCredentialsProvider mockProvider3 = mock(AwsCredentialsProvider.class); + + doThrow(new RuntimeException("Provider 1 failed")).when(mockProvider1).invalidate(any()); + + AwsCredentialsProviderChain chain = AwsCredentialsProviderChain.builder() + .credentialsProviders(mockProvider1, mockProvider2, mockProvider3) + .reuseLastProviderEnabled(false) + .build(); + + AwsCredentialsIdentity identity = AwsBasicCredentials.create("key1", "secret1"); + chain.invalidate(identity).join(); + + verify(mockProvider1, times(1)).invalidate(identity); + verify(mockProvider2, times(1)).invalidate(identity); + verify(mockProvider3, times(1)).invalidate(identity); + } + + private static final class TrackingCredentialsProvider implements AwsCredentialsProvider { + private final AwsBasicCredentials credentials; + int invalidateCallCount = 0; + + TrackingCredentialsProvider(String accessKeyId, String secretAccessKey) { + this.credentials = AwsBasicCredentials.create(accessKeyId, secretAccessKey); + } + + @Override + public AwsCredentials resolveCredentials() { + return credentials; + } + + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + invalidateCallCount++; + return CompletableFuture.completedFuture(null); + } + } + private static final class MockCredentialsProvider implements AwsCredentialsProvider { private final StaticCredentialsProvider staticCredentialsProvider; private final String exceptionMessage; diff --git a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProviderTest.java b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProviderTest.java index 9a5ad4079e65..1b91407efb90 100644 --- a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProviderTest.java +++ b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProviderTest.java @@ -776,6 +776,61 @@ public Instant instant() { } } + @Test + void invalidate_matchingAccessKeyId_invalidatesCache() { + String accessKeyId = "ACCESS_KEY_ID"; + String expirationFarFuture = DateUtils.formatIso8601Date(Instant.now().plus(Duration.ofDays(1))); + String credentialsJson = "{\"AccessKeyId\":\"" + accessKeyId + "\"," + + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY\"," + + "\"Expiration\":\"" + expirationFarFuture + "\"}"; + + stubSecureCredentialsResponse(aResponse().withBody(credentialsJson)); + + InstanceProfileCredentialsProvider provider = InstanceProfileCredentialsProvider.builder().build(); + + AwsCredentials firstCredentials = provider.resolveCredentials(); + assertThat(firstCredentials.accessKeyId()).isEqualTo(accessKeyId); + + String newAccessKeyId = "NEW_ACCESS_KEY_ID"; + String newCredentialsJson = "{\"AccessKeyId\":\"" + newAccessKeyId + "\"," + + "\"SecretAccessKey\":\"NEW_SECRET_ACCESS_KEY\"," + + "\"Expiration\":\"" + expirationFarFuture + "\"}"; + stubSecureCredentialsResponse(aResponse().withBody(newCredentialsJson)); + + AwsCredentialsIdentity identity = AwsBasicCredentials.create(accessKeyId, "SECRET_ACCESS_KEY"); + provider.invalidate(identity).join(); + + AwsCredentials refreshedCredentials = provider.resolveCredentials(); + assertThat(refreshedCredentials.accessKeyId()).isEqualTo(newAccessKeyId); + } + + @Test + void invalidate_nonMatchingAccessKeyId_doesNotInvalidateCache() { + String accessKeyId = "ACCESS_KEY_ID"; + String expirationFarFuture = DateUtils.formatIso8601Date(Instant.now().plus(Duration.ofDays(1))); + String credentialsJson = "{\"AccessKeyId\":\"" + accessKeyId + "\"," + + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY\"," + + "\"Expiration\":\"" + expirationFarFuture + "\"}"; + + stubSecureCredentialsResponse(aResponse().withBody(credentialsJson)); + + InstanceProfileCredentialsProvider provider = InstanceProfileCredentialsProvider.builder().build(); + + AwsCredentials firstCredentials = provider.resolveCredentials(); + assertThat(firstCredentials.accessKeyId()).isEqualTo(accessKeyId); + + String newCredentialsJson = "{\"AccessKeyId\":\"NEW_ACCESS_KEY_ID\"," + + "\"SecretAccessKey\":\"NEW_SECRET_ACCESS_KEY\"," + + "\"Expiration\":\"" + expirationFarFuture + "\"}"; + stubSecureCredentialsResponse(aResponse().withBody(newCredentialsJson)); + + AwsCredentialsIdentity differentIdentity = AwsBasicCredentials.create("DIFFERENT_KEY", "SECRET"); + provider.invalidate(differentIdentity).join(); + + AwsCredentials secondCredentials = provider.resolveCredentials(); + assertThat(secondCredentials.accessKeyId()).isEqualTo(accessKeyId); + } + private static ProfileFileSupplier supply(Iterable iterable) { return iterable.iterator()::next; } diff --git a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/internal/LazyAwsCredentialsProviderTest.java b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/internal/LazyAwsCredentialsProviderTest.java index 43ffcd9cd28e..6d8ed70992ad 100644 --- a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/internal/LazyAwsCredentialsProviderTest.java +++ b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/internal/LazyAwsCredentialsProviderTest.java @@ -15,11 +15,13 @@ package software.amazon.awssdk.auth.credentials.internal; +import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.utils.SdkAutoCloseable; public class LazyAwsCredentialsProviderTest { @@ -75,4 +77,27 @@ public void delegatesClosesInitializerEvenIfGetFails() { private interface CloseableSupplier extends Supplier, SdkAutoCloseable {} private interface CloseableCredentialsProvider extends SdkAutoCloseable, AwsCredentialsProvider {} + + @Test + public void invalidate_whenInitialized_delegatesToInner() { + LazyAwsCredentialsProvider credentialsProvider = LazyAwsCredentialsProvider.create(credentialsConstructor); + credentialsProvider.resolveCredentials(); + + AwsCredentialsIdentity identity = Mockito.mock(AwsCredentialsIdentity.class); + Mockito.when(credentials.invalidate(identity)).thenReturn(CompletableFuture.completedFuture(null)); + + credentialsProvider.invalidate(identity); + + Mockito.verify(credentials).invalidate(identity); + } + + @Test + public void invalidate_whenNotInitialized_doesNotInvokeSupplier() { + LazyAwsCredentialsProvider credentialsProvider = LazyAwsCredentialsProvider.create(credentialsConstructor); + + AwsCredentialsIdentity identity = Mockito.mock(AwsCredentialsIdentity.class); + credentialsProvider.invalidate(identity); + + Mockito.verifyNoMoreInteractions(credentialsConstructor); + } } diff --git a/core/aws-core/src/main/java/software/amazon/awssdk/awscore/exception/AwsServiceException.java b/core/aws-core/src/main/java/software/amazon/awssdk/awscore/exception/AwsServiceException.java index 238666a555ce..26a884d0a103 100644 --- a/core/aws-core/src/main/java/software/amazon/awssdk/awscore/exception/AwsServiceException.java +++ b/core/aws-core/src/main/java/software/amazon/awssdk/awscore/exception/AwsServiceException.java @@ -134,6 +134,19 @@ public boolean isThrottlingException() { .orElse(false); } + /** + * Checks if the exception indicates an authentication error where the credentials were rejected, + * based on the AWS error code (e.g., {@code ExpiredToken}, {@code InvalidToken}, {@code AuthFailure}). + * + * @return true if the AWS error code indicates an authentication error, otherwise false. + */ + @Override + public boolean isAuthenticationError() { + return Optional.ofNullable(awsErrorDetails) + .map(a -> AwsErrorCode.isAuthenticationErrorCode(a.errorCode())) + .orElse(false); + } + /** * @return {@link Builder} instance to construct a new {@link AwsServiceException}. */ diff --git a/core/aws-core/src/main/java/software/amazon/awssdk/awscore/internal/AwsErrorCode.java b/core/aws-core/src/main/java/software/amazon/awssdk/awscore/internal/AwsErrorCode.java index acdac5954713..3dabecf6289a 100644 --- a/core/aws-core/src/main/java/software/amazon/awssdk/awscore/internal/AwsErrorCode.java +++ b/core/aws-core/src/main/java/software/amazon/awssdk/awscore/internal/AwsErrorCode.java @@ -31,6 +31,7 @@ public final class AwsErrorCode { public static final Set THROTTLING_ERROR_CODES; public static final Set DEFINITE_CLOCK_SKEW_ERROR_CODES; public static final Set POSSIBLE_CLOCK_SKEW_ERROR_CODES; + public static final Set AUTH_ERROR_CODES; static { Set throttlingErrorCodes = new HashSet<>(9); @@ -66,6 +67,11 @@ public final class AwsErrorCode { retryableErrorCodes.add("RequestTimeoutException"); retryableErrorCodes.add("InternalError"); RETRYABLE_ERROR_CODES = unmodifiableSet(retryableErrorCodes); + + Set authErrorCodes = new HashSet<>(2); + authErrorCodes.add("ExpiredToken"); + authErrorCodes.add("InvalidToken"); + AUTH_ERROR_CODES = unmodifiableSet(authErrorCodes); } private AwsErrorCode() { @@ -86,4 +92,8 @@ public static boolean isPossibleClockSkewErrorCode(String errorCode) { public static boolean isRetryableErrorCode(String errorCode) { return RETRYABLE_ERROR_CODES.contains(errorCode); } + + public static boolean isAuthenticationErrorCode(String errorCode) { + return AUTH_ERROR_CODES.contains(errorCode); + } } diff --git a/core/identity-spi/src/main/java/software/amazon/awssdk/identity/spi/IdentityProvider.java b/core/identity-spi/src/main/java/software/amazon/awssdk/identity/spi/IdentityProvider.java index 10a9d3b3bb64..0cab117551db 100644 --- a/core/identity-spi/src/main/java/software/amazon/awssdk/identity/spi/IdentityProvider.java +++ b/core/identity-spi/src/main/java/software/amazon/awssdk/identity/spi/IdentityProvider.java @@ -157,4 +157,22 @@ default CompletableFuture resolveIdentity(Consumer resolveIdentity() { return resolveIdentity(ResolveIdentityRequest.builder().build()); } + + /** + * Invalidate cached credentials associated with the given rejected identity. + * + *

When a target service rejects credentials with an authentication error, + * the SDK calls this method so the provider can mark its cache for refresh. + * The next call to {@link #resolveIdentity} will attempt to fetch fresh credentials. + * + *

Implementations MUST only invalidate if the currently-cached identity matches + * the rejected identity (e.g., same access key ID). + * + *

The default implementation is a no-op, suitable for providers that do not cache. + * + * @param identity The identity that was rejected by the service. + */ + default CompletableFuture invalidate(IdentityT identity) { + return CompletableFuture.completedFuture(null); + } } diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/SelectedAuthScheme.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/SelectedAuthScheme.java index 8772260e3042..26f1645692d0 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/SelectedAuthScheme.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/SelectedAuthScheme.java @@ -20,6 +20,7 @@ import software.amazon.awssdk.http.auth.spi.scheme.AuthSchemeOption; import software.amazon.awssdk.http.auth.spi.signer.HttpSigner; import software.amazon.awssdk.identity.spi.Identity; +import software.amazon.awssdk.identity.spi.IdentityProvider; import software.amazon.awssdk.utils.Validate; @@ -42,19 +43,33 @@ /// - identity: CompletableFuture ← the resolved identity! /// - signer: HttpSigner /// - authSchemeOption: AuthSchemeOption +/// - identityProvider: IdentityProvider ← the provider that resolved the identity /// ``` @SdkProtectedApi public final class SelectedAuthScheme { private final CompletableFuture identity; private final HttpSigner signer; private final AuthSchemeOption authSchemeOption; + private final IdentityProvider identityProvider; + /** + * @deprecated Use {@link #builder()} instead. + */ + @Deprecated public SelectedAuthScheme(CompletableFuture identity, HttpSigner signer, AuthSchemeOption authSchemeOption) { this.identity = Validate.paramNotNull(identity, "identity"); this.signer = Validate.paramNotNull(signer, "signer"); this.authSchemeOption = Validate.paramNotNull(authSchemeOption, "authSchemeOption"); + this.identityProvider = null; + } + + private SelectedAuthScheme(BuilderImpl builder) { + this.identity = Validate.paramNotNull(builder.identity, "identity"); + this.signer = Validate.paramNotNull(builder.signer, "signer"); + this.authSchemeOption = Validate.paramNotNull(builder.authSchemeOption, "authSchemeOption"); + this.identityProvider = builder.identityProvider; } public CompletableFuture identity() { @@ -68,4 +83,84 @@ public HttpSigner signer() { public AuthSchemeOption authSchemeOption() { return authSchemeOption; } + + public IdentityProvider identityProvider() { + return identityProvider; + } + + /** + * Returns a builder initialized with the values from this instance, allowing modification + * of individual fields while preserving the rest. + */ + public Builder toBuilder() { + return new BuilderImpl() + .identity(this.identity) + .signer(this.signer) + .authSchemeOption(this.authSchemeOption) + .identityProvider(this.identityProvider); + } + + public static Builder builder() { + return new BuilderImpl<>(); + } + + public interface Builder { + /** + * The resolved identity future for this auth scheme. + */ + Builder identity(CompletableFuture identity); + + /** + * The signer to use for this auth scheme. + */ + Builder signer(HttpSigner signer); + + /** + * The auth scheme option containing signer and identity properties. + */ + Builder authSchemeOption(AuthSchemeOption authSchemeOption); + + /** + * The identity provider that resolved the identity. Used for invalidation on auth errors. + */ + Builder identityProvider(IdentityProvider identityProvider); + + SelectedAuthScheme build(); + } + + private static final class BuilderImpl implements Builder { + private CompletableFuture identity; + private HttpSigner signer; + private AuthSchemeOption authSchemeOption; + private IdentityProvider identityProvider; + + @Override + public Builder identity(CompletableFuture identity) { + this.identity = identity; + return this; + } + + @Override + public Builder signer(HttpSigner signer) { + this.signer = signer; + return this; + } + + @Override + public Builder authSchemeOption(AuthSchemeOption authSchemeOption) { + this.authSchemeOption = authSchemeOption; + return this; + } + + @Override + public Builder identityProvider(IdentityProvider identityProvider) { + this.identityProvider = identityProvider; + return this; + } + + @Override + public SelectedAuthScheme build() { + return new SelectedAuthScheme<>(this); + } + } } diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/exception/SdkServiceException.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/exception/SdkServiceException.java index 6403c4e74fdc..46138fa0fd79 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/exception/SdkServiceException.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/exception/SdkServiceException.java @@ -102,6 +102,20 @@ public boolean isRetryableException() { return false; } + /** + * Specifies whether an exception indicates an authentication error where the credentials used for the request + * were rejected because they are invalid or expired. This method must return {@code false} for authorization + * errors such as {@code AccessDenied}, where the credentials are valid but lack permission for the requested action. + * + *

When this returns {@code true}, the SDK may invalidate cached credentials so that the next attempt + * resolves fresh ones. + * + * @return true if the exception is classified as an authentication error, otherwise false. + */ + public boolean isAuthenticationError() { + return false; + } + /** * @return {@link Builder} instance to construct a new {@link SdkServiceException}. */ diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/http/auth/AuthSchemeResolver.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/http/auth/AuthSchemeResolver.java index ac795a34c494..51f46a5ffb1b 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/http/auth/AuthSchemeResolver.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/http/auth/AuthSchemeResolver.java @@ -155,11 +155,12 @@ public static SelectedAuthScheme mergePreExistingAuthSch AuthSchemeOption.Builder mergedOption = selectedAuthScheme.authSchemeOption().toBuilder(); existingAuthScheme.authSchemeOption().forEachSignerProperty(mergedOption::putSignerPropertyIfAbsent); existingAuthScheme.authSchemeOption().forEachIdentityProperty(mergedOption::putIdentityPropertyIfAbsent); - return new SelectedAuthScheme<>( - selectedAuthScheme.identity(), - selectedAuthScheme.signer(), - mergedOption.build() - ); + return SelectedAuthScheme.builder() + .identity(selectedAuthScheme.identity()) + .signer(selectedAuthScheme.signer()) + .authSchemeOption(mergedOption.build()) + .identityProvider(selectedAuthScheme.identityProvider()) + .build(); } // Start with the freshly resolved auth scheme as the base. @@ -181,11 +182,12 @@ public void accept(SignerProperty key, S value) { existingAuthScheme.authSchemeOption().forEachIdentityProperty(mergedOption::putIdentityPropertyIfAbsent); - return new SelectedAuthScheme<>( - selectedAuthScheme.identity(), - selectedAuthScheme.signer(), - mergedOption.build() - ); + return SelectedAuthScheme.builder() + .identity(selectedAuthScheme.identity()) + .signer(selectedAuthScheme.signer()) + .authSchemeOption(mergedOption.build()) + .identityProvider(selectedAuthScheme.identityProvider()) + .build(); } /** @@ -241,9 +243,12 @@ public void accept(SignerProperty key, S value) { // Only update SELECTED_AUTH_SCHEME if at least one property was re-applied. if (changed[0]) { attrs.putAttribute(SdkInternalExecutionAttribute.SELECTED_AUTH_SCHEME, - new SelectedAuthScheme<>(currentScheme.identity(), - currentScheme.signer(), - mergedOption.build())); + SelectedAuthScheme.builder() + .identity(currentScheme.identity()) + .signer(currentScheme.signer()) + .authSchemeOption(mergedOption.build()) + .identityProvider(currentScheme.identityProvider()) + .build()); } } @@ -281,7 +286,12 @@ private static SelectedAuthScheme trySelectAuthScheme( CompletableFuture identity = resolveIdentity( identityProvider, identityRequestBuilder.build(), metricCollector); - return new SelectedAuthScheme<>(identity, signer, authOption); + return SelectedAuthScheme.builder() + .identity(identity) + .signer(signer) + .authSchemeOption(authOption) + .identityProvider(identityProvider) + .build(); } private static CompletableFuture resolveIdentity( diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AuthSchemeResolutionStage.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AuthSchemeResolutionStage.java index 831340b29d16..ae1a2a941c74 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AuthSchemeResolutionStage.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AuthSchemeResolutionStage.java @@ -151,7 +151,12 @@ private void updateIdentityOnExistingScheme(SelectedAuthSch } CompletableFuture identity = identityProvider.resolveIdentity(ResolveIdentityRequest.builder().build()); executionAttributes.putAttribute(SdkInternalExecutionAttribute.SELECTED_AUTH_SCHEME, - new SelectedAuthScheme<>(identity, existing.signer(), existing.authSchemeOption())); + SelectedAuthScheme.builder() + .identity(identity) + .signer(existing.signer()) + .authSchemeOption(existing.authSchemeOption()) + .identityProvider(identityProvider) + .build()); } private void recordBusinessMetrics(SelectedAuthScheme selectedAuthScheme, diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelper.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelper.java new file mode 100644 index 000000000000..90e20203ee06 --- /dev/null +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelper.java @@ -0,0 +1,90 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.core.internal.http.pipeline.stages.utils; + +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.core.SelectedAuthScheme; +import software.amazon.awssdk.core.exception.SdkServiceException; +import software.amazon.awssdk.core.interceptor.SdkInternalExecutionAttribute; +import software.amazon.awssdk.core.internal.http.RequestExecutionContext; +import software.amazon.awssdk.identity.spi.Identity; +import software.amazon.awssdk.identity.spi.IdentityProvider; +import software.amazon.awssdk.utils.CompletableFutureUtils; +import software.amazon.awssdk.utils.Logger; + +/** + * Utility that detects authentication error responses and triggers credential invalidation + * on the identity provider that produced the rejected credentials. + * + *

When a service returns an authentication error (as determined by + * {@link SdkServiceException#isAuthenticationError()}), this helper retrieves the + * {@link SelectedAuthScheme} from the execution context and calls + * {@link IdentityProvider#invalidate} so the next retry attempt resolves fresh credentials. + * + *

All exceptions from the invalidation path are caught and logged at debug level. + * Invalidation failures never disrupt the normal request/retry flow. + */ +@SdkInternalApi +public final class AuthErrorInvalidationHelper { + + private static final Logger LOG = Logger.loggerFor(AuthErrorInvalidationHelper.class); + + private AuthErrorInvalidationHelper() { + } + + /** + * Checks whether the given exception is an auth error that should trigger + * credential invalidation. If so, retrieves the identity provider from the + * {@link SelectedAuthScheme} and calls invalidate() on it. + * + * @param exception The exception from the failed request attempt + * @param context The request execution context containing auth scheme info + */ + public static void invalidateIfAuthError(Throwable exception, RequestExecutionContext context) { + if (!(exception instanceof SdkServiceException)) { + return; + } + + SdkServiceException serviceException = (SdkServiceException) exception; + if (!serviceException.isAuthenticationError()) { + return; + } + + SelectedAuthScheme selectedAuthScheme = + context.executionAttributes().getAttribute(SdkInternalExecutionAttribute.SELECTED_AUTH_SCHEME); + + if (selectedAuthScheme == null || selectedAuthScheme.identityProvider() == null) { + return; + } + + try { + doInvalidate(selectedAuthScheme); + } catch (Exception e) { + LOG.debug(() -> "Failed to invalidate identity provider after auth error: " + e.getMessage(), e); + } + } + + @SuppressWarnings("unchecked") + private static void doInvalidate(SelectedAuthScheme selectedAuthScheme) { + T resolvedIdentity = CompletableFutureUtils.joinLikeSync(selectedAuthScheme.identity()); + IdentityProvider provider = selectedAuthScheme.identityProvider(); + try { + CompletableFutureUtils.joinLikeSync(provider.invalidate(resolvedIdentity)); + } catch (Exception e) { + LOG.debug(() -> "Failed to invalidate identity provider: " + e.getMessage(), e); + } + } +} diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/RetryableStageHelper.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/RetryableStageHelper.java index 840df78367fd..35d4dab90b8a 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/RetryableStageHelper.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/RetryableStageHelper.java @@ -138,6 +138,9 @@ public void recordAttemptSucceeded() { * code should not retry. */ public Either tryRefreshToken(Duration suggestedDelay) { + // Invalidate cached credentials if this failure is an auth error, before the retry strategy evaluates. + AuthErrorInvalidationHelper.invalidateIfAuthError(this.lastException, context); + RetryToken retryToken = context.executionAttributes().getAttribute(RETRY_TOKEN); RefreshRetryTokenResponse refreshResponse; try { diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelperTest.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelperTest.java new file mode 100644 index 000000000000..62b8b6e4aca9 --- /dev/null +++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelperTest.java @@ -0,0 +1,293 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.core.internal.http.pipeline.stages.utils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.mockito.Mockito.mock; + +import java.io.IOException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import software.amazon.awssdk.core.SdkRequest; +import software.amazon.awssdk.core.SelectedAuthScheme; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.core.exception.SdkServiceException; +import software.amazon.awssdk.core.http.ExecutionContext; +import software.amazon.awssdk.core.interceptor.ExecutionAttributes; +import software.amazon.awssdk.core.interceptor.SdkInternalExecutionAttribute; +import software.amazon.awssdk.core.internal.http.RequestExecutionContext; +import software.amazon.awssdk.http.auth.spi.scheme.AuthSchemeOption; +import software.amazon.awssdk.http.auth.spi.signer.HttpSigner; +import software.amazon.awssdk.identity.spi.Identity; +import software.amazon.awssdk.identity.spi.IdentityProvider; +import software.amazon.awssdk.identity.spi.ResolveIdentityRequest; + +/** + * Unit tests for {@link AuthErrorInvalidationHelper}. + */ +public class AuthErrorInvalidationHelperTest { + + @ParameterizedTest + @ValueSource(strings = {"ExpiredToken", "InvalidToken"}) + void invalidateIfAuthError_whenAuthErrorCode_triggersInvalidation(String errorCode) { + TrackingIdentityProvider provider = new TrackingIdentityProvider(); + TestIdentity identity = new TestIdentity(); + RequestExecutionContext context = contextWithProvider(provider, identity); + Throwable exception = serviceExceptionWithErrorCode(errorCode); + + AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context); + + assertThat(provider.invalidateCalled()).isTrue(); + assertThat(provider.lastInvalidatedIdentity()).isSameAs(identity); + } + + @Test + void invalidateIfAuthError_whenAccessDenied_doesNotTriggerInvalidation() { + TrackingIdentityProvider provider = new TrackingIdentityProvider(); + TestIdentity identity = new TestIdentity(); + RequestExecutionContext context = contextWithProvider(provider, identity); + Throwable exception = serviceExceptionWithErrorCode("AccessDenied"); + + AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context); + + assertThat(provider.invalidateCalled()).isFalse(); + } + + @ParameterizedTest + @ValueSource(strings = {"ThrottlingException", "InternalServerError", "ValidationException", "ResourceNotFoundException", "AuthFailure"}) + void invalidateIfAuthError_whenUnknownErrorCode_doesNotTriggerInvalidation(String errorCode) { + TrackingIdentityProvider provider = new TrackingIdentityProvider(); + TestIdentity identity = new TestIdentity(); + RequestExecutionContext context = contextWithProvider(provider, identity); + Throwable exception = serviceExceptionWithErrorCode(errorCode); + + AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context); + + assertThat(provider.invalidateCalled()).isFalse(); + } + + @ParameterizedTest + @MethodSource("nonServiceExceptions") + void invalidateIfAuthError_whenNonSdkServiceException_doesNotTriggerInvalidation(Throwable exception) { + TrackingIdentityProvider provider = new TrackingIdentityProvider(); + TestIdentity identity = new TestIdentity(); + RequestExecutionContext context = contextWithProvider(provider, identity); + + AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context); + + assertThat(provider.invalidateCalled()).isFalse(); + } + + static Stream nonServiceExceptions() { + return Stream.of( + new RuntimeException("something went wrong"), + SdkClientException.create("client error"), + new IOException("io error") + ); + } + + @Test + void invalidateIfAuthError_whenSelectedAuthSchemeIsNull_doesNotThrow() { + RequestExecutionContext context = contextWithNoAuthScheme(); + Throwable exception = serviceExceptionWithErrorCode("ExpiredToken"); + + assertThatNoException().isThrownBy(() -> + AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context) + ); + } + + @Test + void invalidateIfAuthError_whenIdentityProviderIsNull_doesNotThrow() { + TestIdentity identity = new TestIdentity(); + SelectedAuthScheme selectedAuthScheme = new SelectedAuthScheme<>( + CompletableFuture.completedFuture(identity), + mockSigner(), + AuthSchemeOption.builder().schemeId("test").build() + ); + + RequestExecutionContext context = contextWithSelectedAuthScheme(selectedAuthScheme); + Throwable exception = serviceExceptionWithErrorCode("ExpiredToken"); + + assertThatNoException().isThrownBy(() -> + AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context) + ); + } + + @Test + void invalidateIfAuthError_whenInvalidateThrowsException_doesNotPropagate() { + ThrowingIdentityProvider provider = new ThrowingIdentityProvider(); + TestIdentity identity = new TestIdentity(); + SelectedAuthScheme selectedAuthScheme = SelectedAuthScheme.builder() + .identity(CompletableFuture.completedFuture(identity)) + .signer(mockSigner()) + .authSchemeOption(AuthSchemeOption.builder().schemeId("test").build()) + .identityProvider(provider) + .build(); + + RequestExecutionContext context = contextWithSelectedAuthScheme(selectedAuthScheme); + Throwable exception = serviceExceptionWithErrorCode("ExpiredToken"); + + assertThatNoException().isThrownBy(() -> + AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context) + ); + } + + // --- Helper methods --- + + private RequestExecutionContext contextWithProvider(TrackingIdentityProvider provider, TestIdentity identity) { + SelectedAuthScheme selectedAuthScheme = SelectedAuthScheme.builder() + .identity(CompletableFuture.completedFuture(identity)) + .signer(mockSigner()) + .authSchemeOption(AuthSchemeOption.builder().schemeId("test").build()) + .identityProvider(provider) + .build(); + return contextWithSelectedAuthScheme(selectedAuthScheme); + } + + private RequestExecutionContext contextWithSelectedAuthScheme(SelectedAuthScheme selectedAuthScheme) { + ExecutionAttributes executionAttributes = ExecutionAttributes.builder() + .put(SdkInternalExecutionAttribute.SELECTED_AUTH_SCHEME, selectedAuthScheme) + .build(); + + ExecutionContext executionContext = ExecutionContext.builder() + .executionAttributes(executionAttributes) + .build(); + + return RequestExecutionContext.builder() + .executionContext(executionContext) + .originalRequest(mock(SdkRequest.class)) + .build(); + } + + private RequestExecutionContext contextWithNoAuthScheme() { + ExecutionAttributes executionAttributes = ExecutionAttributes.builder().build(); + + ExecutionContext executionContext = ExecutionContext.builder() + .executionAttributes(executionAttributes) + .build(); + + return RequestExecutionContext.builder() + .executionContext(executionContext) + .originalRequest(mock(SdkRequest.class)) + .build(); + } + + /** + * Creates an SdkServiceException subclass that overrides {@code isAuthenticationError()} to + * return true for the known auth error codes. + */ + private Throwable serviceExceptionWithErrorCode(String errorCode) { + return new TestAwsServiceException(errorCode); + } + + @SuppressWarnings("unchecked") + private static HttpSigner mockSigner() { + return (HttpSigner) mock(HttpSigner.class); + } + + // --- Test doubles --- + + /** + * A simple identity implementation for testing. + */ + private static class TestIdentity implements Identity { + } + + /** + * An identity provider that tracks whether invalidate() was called. + */ + private static class TrackingIdentityProvider implements IdentityProvider { + private final AtomicBoolean invalidateCalled = new AtomicBoolean(false); + private final AtomicReference lastInvalidatedIdentity = new AtomicReference<>(); + + @Override + public Class identityType() { + return TestIdentity.class; + } + + @Override + public CompletableFuture resolveIdentity(ResolveIdentityRequest request) { + return CompletableFuture.completedFuture(new TestIdentity()); + } + + @Override + public CompletableFuture invalidate(TestIdentity identity) { + invalidateCalled.set(true); + lastInvalidatedIdentity.set(identity); + return CompletableFuture.completedFuture(null); + } + + boolean invalidateCalled() { + return invalidateCalled.get(); + } + + TestIdentity lastInvalidatedIdentity() { + return lastInvalidatedIdentity.get(); + } + } + + /** + * An identity provider that throws on invalidate() — used to test exception isolation. + */ + private static class ThrowingIdentityProvider implements IdentityProvider { + @Override + public Class identityType() { + return TestIdentity.class; + } + + @Override + public CompletableFuture resolveIdentity(ResolveIdentityRequest request) { + return CompletableFuture.completedFuture(new TestIdentity()); + } + + @Override + public CompletableFuture invalidate(TestIdentity identity) { + throw new RuntimeException("Simulated invalidation failure"); + } + } + + /** + * Simulates an AwsServiceException that reports authentication errors via the + * {@link SdkServiceException#isAuthenticationError()} virtual method. + */ + private static class TestAwsServiceException extends SdkServiceException { + private static final Set AUTH_ERROR_CODES = new HashSet<>(Arrays.asList( + "ExpiredToken", "InvalidToken" + )); + + private final String errorCode; + + TestAwsServiceException(String errorCode) { + super(SdkServiceException.builder().message("test exception").statusCode(401)); + this.errorCode = errorCode; + } + + @Override + public boolean isAuthenticationError() { + return AUTH_ERROR_CODES.contains(errorCode); + } + } +} diff --git a/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProvider.java b/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProvider.java index be2b61486860..8515b4900b79 100644 --- a/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProvider.java +++ b/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProvider.java @@ -25,6 +25,7 @@ import java.time.Duration; import java.time.Instant; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import software.amazon.awssdk.annotations.NotThreadSafe; import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.annotations.SdkPublicApi; @@ -35,6 +36,7 @@ import software.amazon.awssdk.core.SdkPlugin; import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.services.signin.SigninClient; import software.amazon.awssdk.services.signin.internal.AccessTokenManager; import software.amazon.awssdk.services.signin.internal.DpopAuthPlugin; @@ -129,7 +131,7 @@ private LoginCredentialsProvider(BuilderImpl builder) { CachedSupplier.builder(this::updateSigninCredentials) .cachedValueName(toString()) .staleValueBehavior(ALLOW) - .cacheInvalidatingPredicate(LoginCredentialsProvider::isCacheInvalidating); + .nonRecoverableErrorPredicate(LoginCredentialsProvider::isNonRecoverableError); if (builder.asyncCredentialUpdateEnabled) { cacheBuilder.prefetchStrategy(new NonBlocking(ASYNC_THREAD_NAME)); } @@ -217,11 +219,11 @@ private RefreshResult refreshFromSigninService(LoginAccessToken switch (accessDeniedException.error()) { case TOKEN_EXPIRED: case USER_CREDENTIALS_CHANGED: - // Let the original AccessDeniedException propagate — the cacheInvalidatingPredicate + // Let the original AccessDeniedException propagate — the nonRecoverableErrorPredicate // on CachedSupplier will identify it and bypass static stability. throw accessDeniedException; case INSUFFICIENT_PERMISSIONS: - // Wrap with a helpful message, but still cache-invalidating — the predicate checks the cause. + // Wrap with a helpful message, but still non-recoverable — the predicate checks the cause. throw SdkClientException.create( "Unable to refresh credentials due to insufficient permissions. You may be missing permission " + "for the 'CreateOAuth2Token' action.", @@ -243,7 +245,7 @@ private RefreshResult refreshFromSigninService(LoginAccessToken * {@link OAuth2ErrorCode#USER_CREDENTIALS_CHANGED}, or {@link OAuth2ErrorCode#INSUFFICIENT_PERMISSIONS} * */ - private static boolean isCacheInvalidating(RuntimeException e) { + private static boolean isNonRecoverableError(RuntimeException e) { if (e instanceof InvalidTokenException) { return true; } @@ -295,6 +297,13 @@ public AwsCredentials resolveCredentials() { return credentialCache.get(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + String rejectedAccessKeyId = identity.accessKeyId(); + credentialCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId())); + return CompletableFuture.completedFuture(null); + } + @Override public void close() { credentialCache.close(); diff --git a/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginProfileCredentialsProviderFactory.java b/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginProfileCredentialsProviderFactory.java index ae5fa97e8589..b1c65e09928d 100644 --- a/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginProfileCredentialsProviderFactory.java +++ b/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginProfileCredentialsProviderFactory.java @@ -15,11 +15,13 @@ package software.amazon.awssdk.services.signin.auth; +import java.util.concurrent.CompletableFuture; import software.amazon.awssdk.annotations.SdkProtectedApi; import software.amazon.awssdk.auth.credentials.AwsCredentials; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.ProfileCredentialsProviderFactory; import software.amazon.awssdk.auth.credentials.ProfileProviderCredentialsContext; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.profiles.Profile; import software.amazon.awssdk.profiles.ProfileProperty; import software.amazon.awssdk.services.signin.SigninClient; @@ -66,6 +68,11 @@ public AwsCredentials resolveCredentials() { return this.credentialsProvider.resolveCredentials(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + return this.credentialsProvider.invalidate(identity); + } + @Override public void close() { IoUtils.closeQuietly(credentialsProvider, null); diff --git a/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProvider.java b/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProvider.java index d81ae3dba549..7b341cc0367d 100644 --- a/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProvider.java +++ b/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProvider.java @@ -22,12 +22,14 @@ import java.time.Duration; import java.time.Instant; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.auth.credentials.AwsCredentials; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.services.sso.SsoClient; import software.amazon.awssdk.services.sso.internal.SessionCredentialsHolder; import software.amazon.awssdk.services.sso.model.GetRoleCredentialsRequest; @@ -99,7 +101,7 @@ private SsoCredentialsProvider(BuilderImpl builder) { CachedSupplier.builder(this::updateSsoCredentials) .cachedValueName(toString()) .staleValueBehavior(ALLOW) - .cacheInvalidatingPredicate( + .nonRecoverableErrorPredicate( e -> e instanceof ExpiredTokenException || e instanceof UnauthorizedException); if (builder.asyncCredentialUpdateEnabled) { cacheBuilder.prefetchStrategy(new NonBlocking(ASYNC_THREAD_NAME)); @@ -168,6 +170,13 @@ public AwsCredentials resolveCredentials() { return credentialCache.get().sessionCredentials(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + String rejectedAccessKeyId = identity.accessKeyId(); + credentialCache.invalidate(holder -> rejectedAccessKeyId.equals(holder.sessionCredentials().accessKeyId())); + return CompletableFuture.completedFuture(null); + } + @Override public void close() { credentialCache.close(); 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 0e7f93219c43..428e2a4fb3f8 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 @@ -20,6 +20,7 @@ import java.nio.file.Paths; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; import software.amazon.awssdk.annotations.SdkProtectedApi; import software.amazon.awssdk.annotations.SdkTestInternalApi; @@ -33,6 +34,7 @@ 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.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.profiles.Profile; import software.amazon.awssdk.profiles.ProfileFile; import software.amazon.awssdk.profiles.ProfileProperty; @@ -137,6 +139,11 @@ public AwsCredentials resolveCredentials() { return this.credentialsProvider.resolveCredentials(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + return this.credentialsProvider.invalidate(identity); + } + @Override public void close() { IoUtils.closeQuietly(credentialsProvider, null); diff --git a/services/sso/src/test/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProviderTest.java b/services/sso/src/test/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProviderTest.java index fc0668a10811..dc0f54193e33 100644 --- a/services/sso/src/test/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProviderTest.java +++ b/services/sso/src/test/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProviderTest.java @@ -27,9 +27,11 @@ import java.util.function.Supplier; import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.services.sso.SsoClient; import software.amazon.awssdk.services.sso.model.GetRoleCredentialsRequest; import software.amazon.awssdk.services.sso.model.GetRoleCredentialsResponse; @@ -209,6 +211,73 @@ public void noCachedCredentials_anyFailure_throwsImmediately() { + @Test + public void invalidate_matchingAccessKeyId_causesRefresh() { + ssoClient = mock(SsoClient.class); + RoleCredentials credentials = RoleCredentials.builder() + .accessKeyId("a") + .secretAccessKey("b") + .sessionToken("c") + .expiration(Instant.now().plus(Duration.ofHours(5)).toEpochMilli()) + .build(); + RoleCredentials credentials2 = RoleCredentials.builder() + .accessKeyId("x") + .secretAccessKey("y") + .sessionToken("z") + .expiration(Instant.now().plus(Duration.ofHours(5)).toEpochMilli()) + .build(); + + Supplier supplier = getRequestSupplier(); + when(ssoClient.getRoleCredentials(supplier.get())) + .thenReturn(getResponse(credentials)) + .thenReturn(getResponse(credentials2)); + + try (SsoCredentialsProvider credentialsProvider = SsoCredentialsProvider.builder() + .refreshRequest(supplier) + .ssoClient(ssoClient) + .build()) { + AwsSessionCredentials first = (AwsSessionCredentials) credentialsProvider.resolveCredentials(); + assertThat(first.accessKeyId()).isEqualTo("a"); + + AwsCredentialsIdentity identity = AwsBasicCredentials.create("a", "b"); + credentialsProvider.invalidate(identity).join(); + + AwsSessionCredentials second = (AwsSessionCredentials) credentialsProvider.resolveCredentials(); + assertThat(second.accessKeyId()).isEqualTo("x"); + } + } + + @Test + public void invalidate_nonMatchingAccessKeyId_doesNotCauseRefresh() { + ssoClient = mock(SsoClient.class); + RoleCredentials credentials = RoleCredentials.builder() + .accessKeyId("a") + .secretAccessKey("b") + .sessionToken("c") + .expiration(Instant.now().plus(Duration.ofHours(5)).toEpochMilli()) + .build(); + + Supplier supplier = getRequestSupplier(); + when(ssoClient.getRoleCredentials(supplier.get())).thenReturn(getResponse(credentials)); + + try (SsoCredentialsProvider credentialsProvider = SsoCredentialsProvider.builder() + .refreshRequest(supplier) + .ssoClient(ssoClient) + .build()) { + AwsSessionCredentials first = (AwsSessionCredentials) credentialsProvider.resolveCredentials(); + assertThat(first.accessKeyId()).isEqualTo("a"); + + AwsCredentialsIdentity identity = AwsBasicCredentials.create("different-key", "b"); + credentialsProvider.invalidate(identity).join(); + + AwsSessionCredentials second = (AwsSessionCredentials) credentialsProvider.resolveCredentials(); + assertThat(second.accessKeyId()).isEqualTo("a"); + callClient(verify(ssoClient, times(1)), Mockito.any()); + } + } + + + private GetRoleCredentialsRequestSupplier getRequestSupplier() { return new GetRoleCredentialsRequestSupplier(GetRoleCredentialsRequest.builder() .accountId("123456789") 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 d6956ac87022..e40017020be5 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 @@ -46,6 +46,7 @@ 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.core.exception.SdkServiceException; import software.amazon.awssdk.profiles.ProfileFile; import software.amazon.awssdk.services.sso.SsoClient; import software.amazon.awssdk.services.sso.auth.ExpiredTokenException; @@ -561,7 +562,10 @@ public void tokenProviderThrowsOnSecondCall_staleCachedCredentialsReturned() { when(mockSsoClient.getRoleCredentials(Mockito.any(GetRoleCredentialsRequest.class))) .thenReturn(GetRoleCredentialsResponse.builder().roleCredentials(roleCredentials).build()); - RuntimeException secondCallError = new RuntimeException("Token refresh failed on second attempt"); + RuntimeException secondCallError = SdkServiceException.builder() + .message("SSO service unavailable") + .statusCode(500) + .build(); when(sdkTokenProvider.resolveToken()) .thenReturn(SsoAccessToken.builder().accessToken("valid-token").expiresAt(Instant.now().plusSeconds(3600)).build()) .thenThrow(secondCallError); diff --git a/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProvider.java b/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProvider.java index 431ffbac43bb..e02c125e7d39 100644 --- a/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProvider.java +++ b/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProvider.java @@ -18,6 +18,7 @@ import java.time.Duration; import java.time.Instant; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import java.util.function.Function; import software.amazon.awssdk.annotations.NotThreadSafe; import software.amazon.awssdk.annotations.SdkPublicApi; @@ -25,6 +26,7 @@ import software.amazon.awssdk.auth.credentials.AwsCredentials; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.services.sts.StsClient; import software.amazon.awssdk.utils.Logger; import software.amazon.awssdk.utils.SdkAutoCloseable; @@ -123,6 +125,13 @@ public void close() { sessionCache.close(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + String rejectedAccessKeyId = identity.accessKeyId(); + sessionCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId())); + return CompletableFuture.completedFuture(null); + } + /** * The amount of time, relative to credential expiration, that defines the mandatory refresh window. When credentials are * within this window, all threads will block until the credentials are updated. diff --git a/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsWebIdentityTokenFileCredentialsProvider.java b/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsWebIdentityTokenFileCredentialsProvider.java index 389f4415bdd9..0fbb775306d0 100644 --- a/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsWebIdentityTokenFileCredentialsProvider.java +++ b/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsWebIdentityTokenFileCredentialsProvider.java @@ -23,6 +23,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; import java.util.function.Supplier; import software.amazon.awssdk.annotations.SdkPublicApi; @@ -32,6 +33,7 @@ import software.amazon.awssdk.auth.credentials.internal.WebIdentityTokenCredentialProperties; import software.amazon.awssdk.core.SdkSystemSetting; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.services.sts.StsClient; import software.amazon.awssdk.services.sts.internal.AssumeRoleWithWebIdentityRequestSupplier; import software.amazon.awssdk.services.sts.model.AssumeRoleWithWebIdentityRequest; @@ -155,6 +157,14 @@ public AwsCredentials resolveCredentials() { return awsCredentials; } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + if (credentialsProvider != null) { + return credentialsProvider.invalidate(identity); + } + return CompletableFuture.completedFuture(null); + } + @Override protected AwsSessionCredentials getUpdatedCredentials(StsClient stsClient) { AssumeRoleWithWebIdentityRequest request = diff --git a/services/sts/src/main/java/software/amazon/awssdk/services/sts/internal/StsProfileCredentialsProviderFactory.java b/services/sts/src/main/java/software/amazon/awssdk/services/sts/internal/StsProfileCredentialsProviderFactory.java index e20236a8652d..7813739f40f9 100644 --- a/services/sts/src/main/java/software/amazon/awssdk/services/sts/internal/StsProfileCredentialsProviderFactory.java +++ b/services/sts/src/main/java/software/amazon/awssdk/services/sts/internal/StsProfileCredentialsProviderFactory.java @@ -16,10 +16,12 @@ package software.amazon.awssdk.services.sts.internal; import java.net.URI; +import java.util.concurrent.CompletableFuture; import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.auth.credentials.AwsCredentials; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.ChildProfileCredentialsProviderFactory; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.profiles.Profile; import software.amazon.awssdk.profiles.ProfileProperty; import software.amazon.awssdk.regions.Region; @@ -117,6 +119,11 @@ public AwsCredentials resolveCredentials() { return this.credentialsProvider.resolveCredentials(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + return this.credentialsProvider.invalidate(identity); + } + @Override public void close() { IoUtils.closeIfCloseable(parentCredentialsProvider, null); diff --git a/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProviderTestBase.java b/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProviderTestBase.java index 10014ddbf7c9..d6849d804dc4 100644 --- a/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProviderTestBase.java +++ b/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProviderTestBase.java @@ -28,9 +28,11 @@ import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.AwsCredentials; import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.services.sts.StsClient; import software.amazon.awssdk.services.sts.endpoints.internal.Arn; import software.amazon.awssdk.services.sts.model.Credentials; @@ -159,6 +161,55 @@ public void initialFetchFailureThrowsException_noCachedCredentials() { } } + @Test + public void invalidate_matchingAccessKeyId_causesRefresh() { + Credentials credentials = Credentials.builder() + .accessKeyId("a").secretAccessKey("b").sessionToken("c") + .expiration(Instant.now().plus(Duration.ofHours(5))) + .build(); + Credentials credentials2 = Credentials.builder() + .accessKeyId("x").secretAccessKey("y").sessionToken("z") + .expiration(Instant.now().plus(Duration.ofHours(5))) + .build(); + RequestT request = getRequest(); + when(callClient(stsClient, request)) + .thenReturn(getResponse(credentials)) + .thenReturn(getResponse(credentials2)); + + try (StsCredentialsProvider credentialsProvider = createCredentialsProviderBuilder(request).stsClient(stsClient).build()) { + AwsCredentials first = credentialsProvider.resolveCredentials(); + assertThat(first.accessKeyId()).isEqualTo("a"); + + AwsCredentialsIdentity identity = AwsBasicCredentials.create("a", "b"); + credentialsProvider.invalidate(identity).join(); + + AwsCredentials second = credentialsProvider.resolveCredentials(); + assertThat(second.accessKeyId()).isEqualTo("x"); + } + } + + @Test + public void invalidate_nonMatchingAccessKeyId_doesNotCauseRefresh() { + Credentials credentials = Credentials.builder() + .accessKeyId("a").secretAccessKey("b").sessionToken("c") + .expiration(Instant.now().plus(Duration.ofHours(5))) + .build(); + RequestT request = getRequest(); + when(callClient(stsClient, request)).thenReturn(getResponse(credentials)); + + try (StsCredentialsProvider credentialsProvider = createCredentialsProviderBuilder(request).stsClient(stsClient).build()) { + AwsCredentials first = credentialsProvider.resolveCredentials(); + assertThat(first.accessKeyId()).isEqualTo("a"); + + AwsCredentialsIdentity identity = AwsBasicCredentials.create("different", "b"); + credentialsProvider.invalidate(identity).join(); + + AwsCredentials second = credentialsProvider.resolveCredentials(); + assertThat(second.accessKeyId()).isEqualTo("a"); + callClient(verify(stsClient, times(1)), Mockito.any()); + } + } + public void callClientWithCredentialsProvider(Instant credentialsExpirationDate, int numTimesInvokeCredentialsProvider, boolean overrideStaleAndPrefetchTimes) { Credentials credentials = Credentials.builder().accessKeyId("a").secretAccessKey("b").sessionToken("c").expiration(credentialsExpirationDate).build(); RequestT request = getRequest(); diff --git a/utils/src/main/java/software/amazon/awssdk/utils/cache/CacheRefreshUtils.java b/utils/src/main/java/software/amazon/awssdk/utils/cache/CacheRefreshUtils.java index 998648a6ca02..f7921f7a2ad2 100644 --- a/utils/src/main/java/software/amazon/awssdk/utils/cache/CacheRefreshUtils.java +++ b/utils/src/main/java/software/amazon/awssdk/utils/cache/CacheRefreshUtils.java @@ -41,7 +41,7 @@ private CacheRefreshUtils() { * dynamically based on the credential's remaining lifetime so that longer-lived credentials begin refreshing * earlier and shorter-lived credentials do not attempt a refresh the moment they are issued. * - *

Dynamic window selection:

+ *

Dynamic window selection: *

    *
  • remaining lifetime < 20 minutes → 5 minute window
  • *
  • 20 minutes ≤ remaining lifetime < 90 minutes → 15 minute window
  • diff --git a/utils/src/main/java/software/amazon/awssdk/utils/cache/CachedSupplier.java b/utils/src/main/java/software/amazon/awssdk/utils/cache/CachedSupplier.java index ab978bb1a7dc..8ce75cf3751e 100644 --- a/utils/src/main/java/software/amazon/awssdk/utils/cache/CachedSupplier.java +++ b/utils/src/main/java/software/amazon/awssdk/utils/cache/CachedSupplier.java @@ -117,7 +117,16 @@ public class CachedSupplier implements Supplier, SdkAutoCloseable { * Predicate that determines whether an exception represents a non-recoverable refresh failure * that should bypass static stability (i.e., be re-thrown immediately without extending expiration). */ - private final Predicate cacheInvalidatingPredicate; + private final Predicate nonRecoverableErrorPredicate; + + /** + * Tracks when the next refresh attempt is allowed after a failure. This is set under {@link #refreshLock} + * and read without the lock (volatile). When non-null and in the future, {@link #get()} returns the cached + * value without contacting the source. + * + *

    This field is NEVER modified by {@code invalidate()} — backoff remains independently tracked. + */ + private volatile Instant nextAllowedRefreshTime; private CachedSupplier(Builder builder) { Validate.notNull(builder.supplier, "builder.supplier"); @@ -128,7 +137,7 @@ private CachedSupplier(Builder builder) { this.staleValueBehavior = Validate.notNull(builder.staleValueBehavior, "builder.staleValueBehavior"); this.clock = Validate.notNull(builder.clock, "builder.clock"); this.cachedValueName = Validate.notNull(builder.cachedValueName, "builder.cachedValueName"); - this.cacheInvalidatingPredicate = builder.cacheInvalidatingPredicate; + this.nonRecoverableErrorPredicate = builder.nonRecoverableErrorPredicate; } /** @@ -143,9 +152,15 @@ public static CachedSupplier.Builder builder(Supplier> v @Override public T get() { if (cacheIsStale()) { + if (refreshRateLimited()) { + return this.cachedValue.value(); + } log.debug(() -> "(" + cachedValueName + ") Cached value is stale and will be refreshed."); refreshCache(); } else if (shouldInitiateCachePrefetch()) { + if (refreshRateLimited()) { + return this.cachedValue.value(); + } log.debug(() -> "(" + cachedValueName + ") Cached value has reached prefetch time and will be refreshed."); prefetchCache(); } @@ -153,6 +168,53 @@ public T get() { return this.cachedValue.value(); } + /** + * Marks the cached value for mandatory refresh if the predicate matches. + * Sets staleTime to now() so the next get() triggers a refresh, subject to + * the refresh backoff gate ({@code nextAllowedRefreshTime}). + * + *

    This method MUST NOT discard the cached value. + * This method MUST NOT clear or modify {@code nextAllowedRefreshTime}. + * + *

    If there is no cached value, this method is a no-op — the predicate will not be called. + * + * @param matchesCachedValue A predicate that returns true if the cached value + * is the one that should be invalidated. The value passed + * to the predicate is guaranteed to be non-null. + */ + public void invalidate(Predicate matchesCachedValue) { + try { + boolean lockAcquired = refreshLock.tryLock(BLOCKING_REFRESH_MAX_WAIT.getSeconds(), TimeUnit.SECONDS); + if (!lockAcquired) { + log.debug(() -> "(" + cachedValueName + ") Unable to acquire lock for invalidation; skipping."); + return; + } + try { + RefreshResult currentCachedValue = this.cachedValue; + if (currentCachedValue == null || currentCachedValue.value() == null) { + return; + } + if (!matchesCachedValue.test(currentCachedValue.value())) { + return; + } + // Set staleTime = now, routing next get() through mandatory refresh + // (subject to nextAllowedRefreshTime gate) + Instant now = clock.instant(); + this.cachedValue = currentCachedValue.toBuilder() + .staleTime(now) + .prefetchTime(now) + .build(); + log.debug(() -> "(" + cachedValueName + ") Cached value invalidated. " + + "Next get() will attempt mandatory refresh."); + } finally { + refreshLock.unlock(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.debug(() -> "(" + cachedValueName + ") Interrupted while invalidating cached value."); + } + } + /** * Determines whether the value in this cache is stale, and all threads should block and wait for an updated value. */ @@ -189,6 +251,16 @@ private boolean shouldInitiateCachePrefetch() { return !clock.instant().isBefore(currentCachedValue.prefetchTime()); } + /** + * Checks whether a refresh backoff is currently active. When a refresh fails, a backoff gate + * ({@link #nextAllowedRefreshTime}) is set. While the gate is in the future, the cached value + * is returned without contacting the credential source. + */ + private boolean refreshRateLimited() { + Instant nextAllowed = this.nextAllowedRefreshTime; + return nextAllowed != null && clock.instant().isBefore(nextAllowed); + } + /** * Initiate a pre-fetch of the data using the configured {@link #prefetchStrategy}. */ @@ -241,6 +313,7 @@ private void refreshCache() { * Perform necessary transformations of the successfully-fetched value based on the stale value behavior of this supplier. */ private RefreshResult handleFetchedSuccess(RefreshResult fetch) { + this.nextAllowedRefreshTime = null; // Clear backoff gate on success Instant now = clock.instant(); if (now.isBefore(fetch.staleTime())) { @@ -283,26 +356,22 @@ private RefreshResult handleFetchFailure(RuntimeException e) { case STRICT: throw e; case ALLOW: - // Cache-invalidating errors bypass static stability - if (cacheInvalidatingPredicate != null && cacheInvalidatingPredicate.test(e)) { + // Non-recoverable errors bypass static stability + if (nonRecoverableErrorPredicate != null && nonRecoverableErrorPredicate.test(e)) { throw e; } - // Uniform random backoff: 5-10 minutes + // Set backoff gate — do NOT modify staleTime/prefetchTime long backoffSeconds = STATIC_STABILITY_BACKOFF_MIN.getSeconds() + jitterRandom.nextInt( (int) (STATIC_STABILITY_BACKOFF_MAX.getSeconds() - STATIC_STABILITY_BACKOFF_MIN.getSeconds() + 1)); - Instant extendedStaleTime = now.plusSeconds(backoffSeconds); + this.nextAllowedRefreshTime = now.plusSeconds(backoffSeconds); log.warn(() -> "(" + cachedValueName + ") Credential refresh failed: " + e.getMessage() - + ". Extending cached credential expiration. A refresh of these credentials" - + " will be attempted again after " + backoffSeconds + " seconds.", e); + + ". Will retry after " + backoffSeconds + " seconds.", e); - return currentCachedValue.toBuilder() - .staleTime(extendedStaleTime) - .prefetchTime(extendedStaleTime) - .build(); + return currentCachedValue; // Return unchanged — staleTime/prefetchTime untouched default: throw new IllegalStateException("Unknown stale-value-behavior: " + staleValueBehavior); } @@ -310,31 +379,21 @@ private RefreshResult handleFetchFailure(RuntimeException e) { // Not yet stale — we're in the prefetch window. Handle failure based on mode. if (staleValueBehavior == StaleValueBehavior.ALLOW) { - if (cacheInvalidatingPredicate != null && cacheInvalidatingPredicate.test(e)) { + if (nonRecoverableErrorPredicate != null && nonRecoverableErrorPredicate.test(e)) { throw e; } - // During prefetch window failure: extend prefetchTime to suppress further attempts. - // Preserve existing staleTime if it is later than the new prefetch time. + + // Set backoff gate — do NOT modify staleTime/prefetchTime long backoffSeconds = STATIC_STABILITY_BACKOFF_MIN.getSeconds() + jitterRandom.nextInt( (int) (STATIC_STABILITY_BACKOFF_MAX.getSeconds() - STATIC_STABILITY_BACKOFF_MIN.getSeconds() + 1)); - Instant extendedPrefetchTime = now.plusSeconds(backoffSeconds); - - // Do not move stale time closer — keep the existing stale time if it's later than the extended prefetch time - Instant currentStaleTime = currentCachedValue.staleTime(); - Instant newStaleTime = (currentStaleTime != null && currentStaleTime.isAfter(extendedPrefetchTime)) - ? currentStaleTime - : extendedPrefetchTime; + this.nextAllowedRefreshTime = now.plusSeconds(backoffSeconds); log.warn(() -> "(" + cachedValueName + ") Credential refresh failed: " + e.getMessage() - + ". Extending cached credential expiration. A refresh of these credentials" - + " will be attempted again after " + backoffSeconds + " seconds.", e); + + ". Will retry after " + backoffSeconds + " seconds.", e); - return currentCachedValue.toBuilder() - .staleTime(newStaleTime) - .prefetchTime(extendedPrefetchTime) - .build(); + return currentCachedValue; // Return unchanged — staleTime/prefetchTime untouched } return currentCachedValue; @@ -423,7 +482,7 @@ public static final class Builder { private StaleValueBehavior staleValueBehavior = StaleValueBehavior.STRICT; private Clock clock = Clock.systemUTC(); private String cachedValueName = "unknown"; - private Predicate cacheInvalidatingPredicate; + private Predicate nonRecoverableErrorPredicate; private Builder(Supplier> supplier) { this.supplier = supplier; @@ -470,13 +529,13 @@ public Builder cachedValueName(String cachedValueName) { * *

    This is used for errors where the credential source has definitively indicated that the current * authentication state is invalid and requires user intervention (e.g., expired SSO tokens, - * changed user credentials).

    + * changed user credentials). * - *

    By default, no exceptions are considered cache-invalidating (all failures trigger static stability - * backoff when {@link StaleValueBehavior#ALLOW} is configured).

    + *

    By default, no exceptions are considered non-recoverable (all failures trigger static stability + * backoff when {@link StaleValueBehavior#ALLOW} is configured). */ - public Builder cacheInvalidatingPredicate(Predicate cacheInvalidatingPredicate) { - this.cacheInvalidatingPredicate = cacheInvalidatingPredicate; + public Builder nonRecoverableErrorPredicate(Predicate nonRecoverableErrorPredicate) { + this.nonRecoverableErrorPredicate = nonRecoverableErrorPredicate; return this; } @@ -558,11 +617,11 @@ public enum StaleValueBehavior { * Allow stale values to be returned from the cache with static stability semantics. On refresh failure, * extends the stale time by a uniformly random backoff between 5 and 10 minutes (300-600 seconds). * - *

    If a {@link Builder#cacheInvalidatingPredicate(Predicate)} is configured and returns {@code true} - * for the exception, it is re-thrown immediately without extending the stale time.

    + *

    If a {@link Builder#nonRecoverableErrorPredicate(Predicate)} is configured and returns {@code true} + * for the exception, it is re-thrown immediately without extending the stale time. * *

    Value retrieval will never fail as long as the cache has succeeded at least once, - * unless the error is cache-invalidating.

    + * unless the error is non-recoverable. */ ALLOW } diff --git a/utils/src/test/java/software/amazon/awssdk/utils/cache/CachedSupplierTest.java b/utils/src/test/java/software/amazon/awssdk/utils/cache/CachedSupplierTest.java index 60a55265fba6..e6bb3bbf1bdc 100644 --- a/utils/src/test/java/software/amazon/awssdk/utils/cache/CachedSupplierTest.java +++ b/utils/src/test/java/software/amazon/awssdk/utils/cache/CachedSupplierTest.java @@ -35,11 +35,13 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Supplier; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -397,7 +399,7 @@ public void allowMode_cacheInvalidatingError_isRethrown() throws InterruptedExce MutableSupplier supplier = new MutableSupplier(); try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) .staleValueBehavior(ALLOW) - .cacheInvalidatingPredicate( + .nonRecoverableErrorPredicate( e -> e instanceof CacheInvalidatingRuntimeException) .clock(clock) .jitterEnabled(false) @@ -449,30 +451,29 @@ public void allowMode_backoffIsInExpectedRange() throws InterruptedException { supplier.set(new RuntimeException("service unavailable")); cachedSupplier.get(); - // Advance well past the extended time to test that the backoff was applied - // The extended stale time should be: now(61) + [300,600]s(backoff) - // So total offset from epoch: 61 + [300,600] = [361, 661] seconds from original now - Instant minExpectedStale = now.plusSeconds(61 + 300); - Instant maxExpectedStale = now.plusSeconds(61 + 600); + // Now nextAllowedRefreshTime is set to now(61) + [300,600]s + // The cached value should be returned while rate limited + Instant minBackoffEnd = now.plusSeconds(61 + 300); + Instant maxBackoffEnd = now.plusSeconds(61 + 600); - // Advance just before the minimum backoff - should still return cached (not stale yet) - clock.time = minExpectedStale.minusSeconds(1); + // Advance just before the minimum backoff end - should still be rate limited + clock.time = minBackoffEnd.minusSeconds(1); supplier.set(RefreshResult.builder("new-creds") .staleTime(Instant.MAX) .prefetchTime(Instant.MAX) .build()); - // Value not stale yet so should return cached + // Rate limited: returns cached value without contacting source assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); - // Advance past maximum possible backoff - must be stale now and will refresh - clock.time = maxExpectedStale.plusSeconds(1); + // Advance past maximum possible backoff - rate limit expired, will refresh + clock.time = maxBackoffEnd.plusSeconds(1); assertThat(cachedSupplier.get()).isEqualTo("new-creds"); } } } @Test - public void allowMode_prefetchWindowFailure_extendsPrefetchTime() { + public void allowMode_prefetchWindowFailure_setsBackoffGate() { AdjustableClock clock = new AdjustableClock(); MutableSupplier supplier = new MutableSupplier(); try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) @@ -494,17 +495,17 @@ public void allowMode_prefetchWindowFailure_extendsPrefetchTime() { clock.time = now.plusSeconds(61); supplier.set(new RuntimeException("service unavailable")); - // Should return cached value (not throw) and extend prefetch time + // Should return cached value (not throw) and set nextAllowedRefreshTime assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); // Verify that a subsequent call shortly after does NOT attempt another refresh - // (because prefetchTime was extended) + // (because nextAllowedRefreshTime was set as a backoff gate) clock.time = now.plusSeconds(62); supplier.set(RefreshResult.builder("should-not-get-this") .staleTime(Instant.MAX) .prefetchTime(Instant.MAX) .build()); - // The prefetchTime was extended far into the future, so this should still return cached + // The rate limit is active, so this should still return cached assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); } } @@ -536,18 +537,14 @@ public void allowMode_prefetchWindowFailure_preservesStaleTime() { // Trigger failure during prefetch window assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); - // Verify the stale time was preserved (NOT moved to the backoff time). - // The original stale time (now + 3600s) should still be in effect. - // Advance to a time just before original stale time — should NOT be stale. - // The extended prefetchTime was ~now+61+[300,600] = [361,661] from epoch. - // At time 3599, we are past the extended prefetchTime, so a prefetch is triggered. - clock.time = originalStaleTime.minusSeconds(1); + // Advance past the maximum possible backoff (61 + 600 = 661s from now) but still before stale time (3600s). + // The nextAllowedRefreshTime backoff will have elapsed, so a prefetch refresh will be attempted. + clock.time = now.plusSeconds(700); supplier.set(RefreshResult.builder("refreshed-creds") .staleTime(Instant.MAX) .prefetchTime(Instant.MAX) .build()); - // Since stale time is preserved at originalStaleTime (3600), we are NOT stale at 3599. - // The prefetch backoff has long elapsed, so a prefetch refresh will succeed. + // Backoff elapsed, prefetchTime (60s) is in the past, so prefetch is triggered and succeeds assertThat(cachedSupplier.get()).isEqualTo("refreshed-creds"); } } @@ -558,7 +555,7 @@ public void allowMode_prefetchWindowFailure_cacheInvalidatingError_isRethrown() MutableSupplier supplier = new MutableSupplier(); try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) .staleValueBehavior(ALLOW) - .cacheInvalidatingPredicate( + .nonRecoverableErrorPredicate( e -> e instanceof CacheInvalidatingRuntimeException) .clock(clock) .jitterEnabled(false) @@ -873,4 +870,147 @@ public Instant instant() { return time; } } + + // --- invalidate() tests --- + + @Test + public void invalidate_predicateMatches_triggersRefresh() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + try (CachedSupplier cache = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + supplier.set(RefreshResult.builder("value-1").staleTime(now.plusSeconds(3600)).prefetchTime(now.plusSeconds(1800)).build()); + assertThat(cache.get()).isEqualTo("value-1"); + + clock.time = now.plusSeconds(10); + cache.invalidate(v -> v.equals("value-1")); + + supplier.set(RefreshResult.builder("value-2").staleTime(now.plusSeconds(7200)).prefetchTime(now.plusSeconds(5400)).build()); + assertThat(cache.get()).isEqualTo("value-2"); + } + } + + @Test + public void invalidate_predicateDoesNotMatch_doesNotTriggerRefresh() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + try (CachedSupplier cache = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + supplier.set(RefreshResult.builder("value-1").staleTime(now.plusSeconds(3600)).prefetchTime(now.plusSeconds(1800)).build()); + assertThat(cache.get()).isEqualTo("value-1"); + + cache.invalidate(v -> v.equals("different-value")); + + supplier.set(RefreshResult.builder("value-2").staleTime(now.plusSeconds(7200)).prefetchTime(now.plusSeconds(5400)).build()); + assertThat(cache.get()).isEqualTo("value-1"); + } + } + + @Test + public void invalidate_beforeFirstGet_isNoOp() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + clock.time = Instant.parse("2024-01-01T00:00:00Z"); + + try (CachedSupplier cache = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + cache.invalidate(v -> true); // should not throw + + supplier.set(RefreshResult.builder("value-1").staleTime(Instant.MAX).prefetchTime(Instant.MAX).build()); + assertThat(cache.get()).isEqualTo("value-1"); + } + } + + @Test + public void invalidate_doesNotBypassRefreshBackoff() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + try (CachedSupplier cache = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + supplier.set(RefreshResult.builder("old").staleTime(now.plusSeconds(60)).prefetchTime(now.plusSeconds(30)).build()); + assertThat(cache.get()).isEqualTo("old"); + + // Trigger failure to set backoff + clock.time = now.plusSeconds(61); + supplier.set(new RuntimeException("unavailable")); + assertThat(cache.get()).isEqualTo("old"); + + // Invalidate — marks stale but doesn't clear backoff + clock.time = now.plusSeconds(62); + cache.invalidate(v -> v.equals("old")); + supplier.set(RefreshResult.builder("new").staleTime(Instant.MAX).prefetchTime(Instant.MAX).build()); + + // Still within backoff — returns stale + assertThat(cache.get()).isEqualTo("old"); + + // Past backoff — returns fresh + clock.time = now.plusSeconds(700); + assertThat(cache.get()).isEqualTo("new"); + } + } + + @Test + public void invalidate_concurrentWithGet_doesNotCorrupt() throws Exception { + AdjustableClock clock = new AdjustableClock(); + clock.time = Instant.parse("2024-01-01T00:00:00Z"); + AtomicInteger counter = new AtomicInteger(0); + + try (CachedSupplier cache = CachedSupplier.builder(() -> + RefreshResult.builder("v-" + counter.incrementAndGet()) + .staleTime(Instant.MAX) + .prefetchTime(Instant.MAX) + .build()) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + + cache.get(); // prime + + ExecutorService executor = Executors.newFixedThreadPool(10); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + for (int i = 0; i < 10; i++) { + int idx = i; + futures.add(executor.submit(() -> { + try { start.await(); } catch (InterruptedException e) { return; } + for (int j = 0; j < 50; j++) { + if (idx % 2 == 0) { + cache.invalidate(v -> true); + } else { + assertThat(cache.get()).isNotNull(); + } + } + })); + } + + start.countDown(); + for (Future f : futures) { f.get(30, TimeUnit.SECONDS); } + executor.shutdown(); + + assertThat(cache.get()).isNotNull(); + } + } }