diff --git a/.changes/next-release/feature-AWSSDKforJavav2-55b76bf.json b/.changes/next-release/feature-AWSSDKforJavav2-55b76bf.json new file mode 100644 index 000000000000..afbf9065e31c --- /dev/null +++ b/.changes/next-release/feature-AWSSDKforJavav2-55b76bf.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "AWS SDK for Java v2", + "contributor": "", + "description": "Standardize cached credential stale/prefetch windows, improve credential refresh resilliancy (static-stability) and add automatic credential invalidation/refresh." +} diff --git a/.github/workflows/mixed-version-compatibility-review.yml b/.github/workflows/mixed-version-compatibility-review.yml index 75728e32a3ab..39db4290a279 100644 --- a/.github/workflows/mixed-version-compatibility-review.yml +++ b/.github/workflows/mixed-version-compatibility-review.yml @@ -46,11 +46,10 @@ jobs: # Look for new public methods in the changed base class files # Filter out obvious false positives: comments, string literals, javadoc NEW_METHODS=$(git diff remotes/origin/${{ github.base_ref }} -- $BASE_CLASS_FILES | \ - - grep '^+.*public.*(' | \ # Find lines with new public methods - grep -v '^+[[:space:]]*//.*' | \ # Line comments - grep -v '^+[[:space:]]*\*.*' | \ # Javadoc lines - grep -v '^+[[:space:]]*/\*.*' || true # Block comments + grep '^+.*public.*(' | \ + grep -v '^+[[:space:]]*//.*' | \ + grep -v '^+[[:space:]]*\*.*' | \ + grep -v '^+[[:space:]]*/\*.*' || true) if [ -n "$NEW_METHODS" ]; then echo "::warning::New public methods detected in base classes:" 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 5b0f5ab6ae98..9bd0d8eebdec 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 @@ -599,8 +599,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 12bceafed07b..79278139955d 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 @@ -1067,8 +1067,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 299f08752a84..261d40fb50a7 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 @@ -952,8 +952,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 de8be3aa52a1..2347ea916e51 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 71cd3af7868b..e99243a349d8 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 26e225ab6fc5..a817ac3eb13d 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 23ba5b360972..25f08545d74d 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 1d170e6413e8..f6e43b690953 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 57d14ba41164..fb366a2a6cda 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 @@ -16,6 +16,7 @@ package software.amazon.awssdk.auth.credentials; import static java.nio.charset.StandardCharsets.UTF_8; +import static software.amazon.awssdk.utils.cache.CachedSupplier.StaleValueBehavior.ALLOW; import java.io.IOException; import java.net.InetAddress; @@ -24,6 +25,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Arrays; @@ -32,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; @@ -41,14 +44,15 @@ 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.ComparableUtils; import software.amazon.awssdk.utils.StringUtils; import software.amazon.awssdk.utils.ToString; import software.amazon.awssdk.utils.Validate; import software.amazon.awssdk.utils.builder.CopyableBuilder; import software.amazon.awssdk.utils.builder.ToCopyableBuilder; +import software.amazon.awssdk.utils.cache.CacheRefreshUtils; import software.amazon.awssdk.utils.cache.CachedSupplier; import software.amazon.awssdk.utils.cache.NonBlocking; import software.amazon.awssdk.utils.cache.RefreshResult; @@ -85,6 +89,8 @@ public final class ContainerCredentialsProvider private static final List VALID_LOOP_BACK_IPV4 = Arrays.asList(ECS_CONTAINER_HOST, EKS_CONTAINER_HOST_IPV4); private static final List VALID_LOOP_BACK_IPV6 = Arrays.asList(EKS_CONTAINER_HOST_IPV6); + private static final Duration DEFAULT_STALE_TIME = Duration.ofMinutes(1); + private final String endpoint; private final HttpCredentialsLoader httpCredentialsLoader; private final CachedSupplier credentialsCache; @@ -94,6 +100,8 @@ public final class ContainerCredentialsProvider private final String asyncThreadName; private final String sourceChain; private final String providerName; + private final Duration staleTime; + private final Duration prefetchTime; /** * @see #builder() @@ -107,16 +115,24 @@ private ContainerCredentialsProvider(BuilderImpl builder) { ? PROVIDER_NAME : builder.sourceChain + "," + PROVIDER_NAME; this.httpCredentialsLoader = HttpCredentialsLoader.create(this.providerName); + this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); + this.prefetchTime = builder.prefetchTime; + if (this.prefetchTime != null) { + Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, + "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + } if (Boolean.TRUE.equals(builder.asyncCredentialUpdateEnabled)) { Validate.paramNotBlank(builder.asyncThreadName, "asyncThreadName"); this.credentialsCache = CachedSupplier.builder(this::refreshCredentials) .cachedValueName(toString()) .prefetchStrategy(new NonBlocking(builder.asyncThreadName)) + .staleValueBehavior(ALLOW) .build(); } else { this.credentialsCache = CachedSupplier.builder(this::refreshCredentials) .cachedValueName(toString()) + .staleValueBehavior(ALLOW) .build(); } } @@ -153,19 +169,18 @@ private Instant staleTime(Instant expiration) { return null; } - return expiration.minus(1, ChronoUnit.MINUTES); + return expiration.minus(staleTime); } private Instant prefetchTime(Instant expiration) { - Instant oneHourFromNow = Instant.now().plus(1, ChronoUnit.HOURS); - if (expiration == null) { - return oneHourFromNow; + return Instant.now().plus(1, ChronoUnit.HOURS); } - Instant fifteenMinutesBeforeExpiration = expiration.minus(15, ChronoUnit.MINUTES); + Instant now = Instant.now(); + Duration effectivePrefetchWindow = CacheRefreshUtils.computePrefetchWindow(expiration, prefetchTime, now); - return ComparableUtils.minimum(oneHourFromNow, fifteenMinutesBeforeExpiration); + return expiration.minus(effectivePrefetchWindow); } @Override @@ -173,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(); @@ -316,6 +338,43 @@ public boolean isMetadataServiceEndpoint(String host) { */ public interface Builder extends HttpCredentialsProvider.Builder, CopyableBuilder { + + /** + * Configure the amount of time, relative to credential expiration, that defines the mandatory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will block all callers until a refresh attempt completes. If the refresh attempt fails, the provider + * returns the cached credentials and will not attempt another refresh until a backoff period has elapsed. + * + *

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. + * + * @param staleTime the duration before expiration that triggers mandatory (blocking) refresh + */ + Builder staleTime(Duration staleTime); + + /** + * Configure the amount of time, relative to credential expiration, that defines the advisory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will attempt to refresh them proactively. If the refresh fails, the provider returns the existing cached + * credentials without error and will not attempt another refresh until a backoff period has elapsed. + * + *

When {@link #asyncCredentialUpdateEnabled(Boolean)} is true, advisory refreshes happen in a background thread + * and callers immediately receive the current cached credentials. When it is false, one caller will block to perform + * the refresh while other callers receive the current cached credentials. + * + *

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

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. + * + * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh + */ + Builder prefetchTime(Duration prefetchTime); } private static final class BuilderImpl implements Builder { @@ -323,6 +382,8 @@ private static final class BuilderImpl implements Builder { private Boolean asyncCredentialUpdateEnabled; private String asyncThreadName; private String sourceChain; + private Duration staleTime; + private Duration prefetchTime; private BuilderImpl() { asyncThreadName("container-credentials-provider"); @@ -333,6 +394,8 @@ private BuilderImpl(ContainerCredentialsProvider credentialsProvider) { this.asyncCredentialUpdateEnabled = credentialsProvider.asyncCredentialUpdateEnabled; this.asyncThreadName = credentialsProvider.asyncThreadName; this.sourceChain = credentialsProvider.sourceChain; + this.staleTime = credentialsProvider.staleTime; + this.prefetchTime = credentialsProvider.prefetchTime; } @Override @@ -365,6 +428,26 @@ public void setAsyncThreadName(String asyncThreadName) { asyncThreadName(asyncThreadName); } + @Override + public Builder staleTime(Duration staleTime) { + this.staleTime = staleTime; + return this; + } + + public void setStaleTime(Duration staleTime) { + staleTime(staleTime); + } + + @Override + public Builder prefetchTime(Duration prefetchTime) { + this.prefetchTime = prefetchTime; + return this; + } + + public void setPrefetchTime(Duration prefetchTime) { + prefetchTime(prefetchTime); + } + /** * An optional string denoting previous credentials providers that are chained with this one. *

Note: This method is primarily intended for use by AWS SDK internal components 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/HttpCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/HttpCredentialsProvider.java index 29239b34908d..218dc977f1dd 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/HttpCredentialsProvider.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/HttpCredentialsProvider.java @@ -28,9 +28,9 @@ public interface HttpCredentialsProvider extends AwsCredentialsProvider, SdkAutoCloseable { interface Builder> { /** - * Configure whether the provider should fetch credentials asynchronously in the background. If this is true, - * threads are less likely to block when credentials are loaded, but additional resources are used to maintain - * the provider. + * Configure whether the provider should fetch credentials asynchronously in the background. When enabled, a + * dedicated thread performs credential refreshes during the advisory refresh window, so that callers are less + * likely to block waiting for credentials. Additional resources (a thread) are used to maintain the provider. * *

By default, this is disabled.

*/ 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 364cd4401529..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 @@ -16,7 +16,6 @@ package software.amazon.awssdk.auth.credentials; import static java.time.temporal.ChronoUnit.MINUTES; -import static software.amazon.awssdk.utils.ComparableUtils.maximum; import static software.amazon.awssdk.utils.FunctionalUtils.invokeSafely; import static software.amazon.awssdk.utils.cache.CachedSupplier.StaleValueBehavior.ALLOW; @@ -27,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; @@ -38,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; @@ -50,6 +51,7 @@ import software.amazon.awssdk.utils.Validate; import software.amazon.awssdk.utils.builder.CopyableBuilder; import software.amazon.awssdk.utils.builder.ToCopyableBuilder; +import software.amazon.awssdk.utils.cache.CacheRefreshUtils; import software.amazon.awssdk.utils.cache.CachedSupplier; import software.amazon.awssdk.utils.cache.NonBlocking; import software.amazon.awssdk.utils.cache.RefreshResult; @@ -93,6 +95,8 @@ public final class InstanceProfileCredentialsProvider private final Duration staleTime; + private final Duration prefetchTime; + private final String sourceChain; private final String providerName; @@ -120,7 +124,12 @@ private InstanceProfileCredentialsProvider(BuilderImpl builder) { .profileName(profileName) .build(); - this.staleTime = Validate.getOrDefault(builder.staleTime, () -> Duration.ofSeconds(1)); + this.staleTime = Validate.getOrDefault(builder.staleTime, () -> Duration.ofMinutes(1)); + this.prefetchTime = builder.prefetchTime; + if (this.prefetchTime != null) { + Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, + "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + } if (Boolean.TRUE.equals(builder.asyncCredentialUpdateEnabled)) { Validate.paramNotBlank(builder.asyncThreadName, "asyncThreadName"); @@ -160,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."); @@ -204,7 +220,14 @@ private Instant prefetchTime(Instant expiration) { return null; } - return now.plus(maximum(timeUntilExpiration.dividedBy(2), Duration.ofMinutes(5))); + // Use dynamic window when user has not explicitly configured prefetchTime + Duration effectivePrefetchWindow = CacheRefreshUtils.computePrefetchWindow(expiration, prefetchTime, now); + + // If remaining lifetime < the advisory window, refresh immediately. + if (timeUntilExpiration.compareTo(effectivePrefetchWindow) < 0) { + return now; + } + return expiration.minus(effectivePrefetchWindow); } @Override @@ -355,17 +378,42 @@ public interface Builder extends HttpCredentialsProvider.BuilderIncreasing this value to a higher value (10s or more) may help with situations where a higher load on the instance - * metadata service causes it to return 503s error, for which the SDK may not be able to recover fast enough and - * returns expired credentials. + * Configure the amount of time, relative to credential expiration, that defines the mandatory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will block all callers until a refresh attempt completes. If the refresh attempt fails, the provider + * returns the cached credentials and will not attempt another refresh until a backoff period has elapsed. + * + *

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. * - * @param duration the amount of time before expiration for when to consider the credentials to be stale and need refresh + * @param duration the duration before expiration that triggers mandatory (blocking) refresh */ Builder staleTime(Duration duration); + /** + * Configure the amount of time, relative to credential expiration, that defines the advisory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will attempt to refresh them proactively. If the refresh fails, the provider returns the existing cached + * credentials without error and will not attempt another refresh until a backoff period has elapsed. + * + *

When {@link #asyncCredentialUpdateEnabled(Boolean)} is true, advisory refreshes happen in a background thread + * and callers immediately receive the current cached credentials. When it is false, one caller will block to perform + * the refresh while other callers receive the current cached credentials. + * + *

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

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. + * + * @param duration the duration before expiration that triggers advisory (proactive) refresh + */ + Builder prefetchTime(Duration duration); + /** * Build a {@link InstanceProfileCredentialsProvider} from the provided configuration. */ @@ -382,6 +430,7 @@ static final class BuilderImpl implements Builder { private Supplier profileFile; private String profileName; private Duration staleTime; + private Duration prefetchTime; private String sourceChain; private BuilderImpl() { @@ -396,6 +445,7 @@ private BuilderImpl(InstanceProfileCredentialsProvider provider) { this.profileFile = provider.profileFile; this.profileName = provider.profileName; this.staleTime = provider.staleTime; + this.prefetchTime = provider.prefetchTime; this.sourceChain = provider.sourceChain; } @@ -475,6 +525,16 @@ public void setStaleTime(Duration duration) { staleTime(duration); } + @Override + public Builder prefetchTime(Duration duration) { + this.prefetchTime = duration; + return this; + } + + public void setPrefetchTime(Duration duration) { + prefetchTime(duration); + } + /** * An optional string denoting previous credentials providers that are chained with this one. *

Note: This method is primarily intended for use by AWS SDK internal components 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 08d9cf373ab2..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 @@ -24,8 +24,11 @@ import java.util.ArrayList; 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; @@ -37,6 +40,7 @@ import software.amazon.awssdk.utils.Validate; import software.amazon.awssdk.utils.builder.CopyableBuilder; import software.amazon.awssdk.utils.builder.ToCopyableBuilder; +import software.amazon.awssdk.utils.cache.CacheRefreshUtils; import software.amazon.awssdk.utils.cache.CachedSupplier; import software.amazon.awssdk.utils.cache.NonBlocking; import software.amazon.awssdk.utils.cache.RefreshResult; @@ -44,21 +48,33 @@ /** * A credentials provider that can load credentials from an external process. This is used to support the credential_process * setting in the profile credentials file. See - * sourcing credentials - * from external processes for more information. + * sourcing + * credentials from external processes for more information. * - *

- * This class can be initialized using {@link #builder()}. + *

This provider caches credentials returned by the external process and refreshes them before they expire. If a refresh + * attempt fails after credentials have entered the mandatory refresh window, the provider raises an exception to the caller + * (unlike other credential providers that return cached credentials on failure). * - *

- * Available settings: + *

This class can be initialized using {@link #builder()}. + * + *

Available settings:

*
    - *
  • Command - The command that should be executed to retrieve credentials.
  • - *
  • CredentialRefreshThreshold - The amount of time between when the credentials expire and when the credentials should - * start to be refreshed. This allows the credentials to be refreshed *before* they are reported to expire. Default: 15 - * seconds.
  • - *
  • ProcessOutputLimit - The maximum amount of data that can be returned by the external process before an exception is - * raised. Default: 64000 bytes (64KB).
  • + *
  • Command - The command that should be executed to retrieve credentials. Can be specified as a single string + * (deprecated) or as a list of strings.
  • + *
  • StaleTime - The amount of time before credential expiration that defines the mandatory refresh window. When + * credentials are within this window, all callers block until a refresh attempt completes. If the refresh fails, an + * exception is raised. Default: 1 minute.
  • + *
  • PrefetchTime - The amount of time before credential expiration that defines the advisory refresh window. When + * credentials are within this window, the provider proactively attempts to refresh them. If the refresh fails during the + * advisory window, the existing cached credentials are returned without error. This replaces the deprecated + * {@code credentialRefreshThreshold} setting; if that setting was explicitly configured, its value is honored as the + * prefetch time for backward compatibility. Default: 5 minutes.
  • + *
  • AsyncCredentialUpdateEnabled - Whether to refresh credentials asynchronously in a background thread during + * the advisory refresh window, so that callers are less likely to block. Default: disabled.
  • + *
  • ProcessOutputLimit - The maximum amount of data that can be returned by the external process before an + * exception is raised. Default: 64000 bytes (64KB).
  • + *
  • CredentialRefreshThreshold - Deprecated. Use {@code prefetchTime} instead. If explicitly set, the + * value is honored as the prefetch time for backward compatibility.
  • *
*/ @SdkPublicApi @@ -71,9 +87,9 @@ public final class ProcessCredentialsProvider private static final JsonNodeParser PARSER = JsonNodeParser.builder() .removeErrorLocations(true) .build(); + private static final Duration DEFAULT_STALE_TIME = Duration.ofMinutes(1); private final List executableCommand; - private final Duration credentialRefreshThreshold; private final long processOutputLimit; private final String staticAccountId; @@ -87,6 +103,8 @@ public final class ProcessCredentialsProvider private final String sourceChain; private final String providerName; + private final Duration staleTime; + private final Duration prefetchTime; /** * @see #builder() @@ -94,7 +112,6 @@ public final class ProcessCredentialsProvider private ProcessCredentialsProvider(Builder builder) { this.executableCommand = executableCommand(builder); this.processOutputLimit = Validate.isPositive(builder.processOutputLimit, "processOutputLimit"); - this.credentialRefreshThreshold = Validate.isPositive(builder.credentialRefreshThreshold, "expirationBuffer"); this.commandFromBuilder = builder.command; this.commandAsListOfStringsFromBuilder = builder.commandAsListOfStrings; this.asyncCredentialUpdateEnabled = builder.asyncCredentialUpdateEnabled; @@ -103,6 +120,12 @@ private ProcessCredentialsProvider(Builder builder) { this.providerName = StringUtils.isEmpty(builder.sourceChain) ? PROVIDER_NAME : builder.sourceChain + "," + PROVIDER_NAME; + this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); + this.prefetchTime = builder.prefetchTime; + if (this.prefetchTime != null) { + Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, + "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + } CachedSupplier.Builder cacheBuilder = CachedSupplier.builder(this::refreshCredentials) .cachedValueName(toString()); @@ -145,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(); @@ -154,8 +184,8 @@ private RefreshResult refreshCredentials() { Instant credentialExpirationTime = credentialExpirationTime(credentialsJson); return RefreshResult.builder(credentials) - .staleTime(credentialExpirationTime) - .prefetchTime(credentialExpirationTime.minusMillis(credentialRefreshThreshold.toMillis())) + .staleTime(staleTime(credentialExpirationTime)) + .prefetchTime(prefetchTime(credentialExpirationTime)) .build(); } catch (InterruptedException e) { throw new IllegalStateException("Process-based credential refreshing has been interrupted.", e); @@ -164,6 +194,22 @@ private RefreshResult refreshCredentials() { } } + private Instant staleTime(Instant expiration) { + if (expiration == null || expiration.equals(Instant.MAX)) { + return Instant.MAX; + } + return expiration.minus(staleTime); + } + + private Instant prefetchTime(Instant expiration) { + if (expiration == null || expiration.equals(Instant.MAX)) { + return Instant.MAX; + } + Instant now = Instant.now(); + Duration dynamicWindow = CacheRefreshUtils.computePrefetchWindow(expiration, prefetchTime, now); + return expiration.minus(dynamicWindow); + } + /** * Parse the output from the credentials process. */ @@ -277,10 +323,11 @@ public static class Builder implements CopyableBuilder commandAsListOfStrings; - private Duration credentialRefreshThreshold = Duration.ofSeconds(15); private long processOutputLimit = 64000; private String staticAccountId; private String sourceChain; + private Duration staleTime; + private Duration prefetchTime; /** * @see #builder() @@ -292,16 +339,21 @@ private Builder(ProcessCredentialsProvider provider) { this.asyncCredentialUpdateEnabled = provider.asyncCredentialUpdateEnabled; this.command = provider.commandFromBuilder; this.commandAsListOfStrings = provider.commandAsListOfStringsFromBuilder; - this.credentialRefreshThreshold = provider.credentialRefreshThreshold; this.processOutputLimit = provider.processOutputLimit; this.staticAccountId = provider.staticAccountId; this.sourceChain = provider.sourceChain; + this.staleTime = provider.staleTime; + this.prefetchTime = provider.prefetchTime; } /** - * Configure whether the provider should fetch credentials asynchronously in the background. If this is true, - * threads are less likely to block when credentials are loaded, but additional resources are used to maintain - * the provider. + * Configure whether the provider should fetch credentials asynchronously in the background. When enabled, a + * dedicated thread performs credential refreshes during the advisory refresh window (defined by + * {@link #prefetchTime(Duration)}), so that callers are less likely to block waiting for credentials. Additional + * resources (a thread) are used to maintain the provider. + * + *

Regardless of this setting, callers will block if credentials enter the mandatory refresh window (defined by + * {@link #staleTime(Duration)}). * *

By default, this is disabled.

*/ @@ -311,6 +363,50 @@ public Builder asyncCredentialUpdateEnabled(Boolean asyncCredentialUpdateEnabled return this; } + /** + * Configure the amount of time, relative to credential expiration, that defines the mandatory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will block all callers until a refresh attempt completes. If the refresh attempt fails, the provider + * raises an exception to the caller. + * + *

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. + * + * @param staleTime the duration before expiration that triggers mandatory (blocking) refresh + */ + public Builder staleTime(Duration staleTime) { + this.staleTime = staleTime; + return this; + } + + /** + * Configure the amount of time, relative to credential expiration, that defines the advisory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will attempt to refresh them proactively. If the refresh fails during the advisory window, the provider + * returns the existing cached credentials. If the refresh fails after credentials have entered the mandatory refresh + * window (defined by {@link #staleTime(Duration)}), the provider raises an exception. + * + *

When {@link #asyncCredentialUpdateEnabled(Boolean)} is true, advisory refreshes happen in a background thread + * and callers immediately receive the current cached credentials. When it is false, one caller will block to perform + * the refresh while other callers receive the current cached credentials. + * + *

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

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. + * + * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh + */ + public Builder prefetchTime(Duration prefetchTime) { + this.prefetchTime = prefetchTime; + return this; + } + /** * Configure the command that should be executed to retrieve credentials. * See {@link ProcessBuilder} for details on how this command is used. @@ -338,10 +434,13 @@ public Builder command(List commandAsListOfStrings) { * Configure the amount of time between when the credentials expire and when the credentials should start to be * refreshed. This allows the credentials to be refreshed *before* they are reported to expire. * - *

Default: 15 seconds.

+ * @deprecated Use {@link #prefetchTime(Duration)} instead. This method has been deprecated for consistency + * with other credential providers. Calls to this method are equivalent to calling + * {@code prefetchTime(credentialRefreshThreshold)}. */ + @Deprecated public Builder credentialRefreshThreshold(Duration credentialRefreshThreshold) { - this.credentialRefreshThreshold = credentialRefreshThreshold; + this.prefetchTime = credentialRefreshThreshold; return this; } 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 6ce5217dc909..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}. */ @@ -189,21 +199,39 @@ public interface Builder extends CopyableBuilderPrefetch updates will occur between the specified time and the stale time of the provider. Prefetch - * updates may be asynchronous. See {@link #asyncCredentialUpdateEnabled}. + *

When {@link #asyncCredentialUpdateEnabled(Boolean)} is true, advisory refreshes happen in a background thread + * and callers immediately receive the current cached credentials. When it is false, one caller will block to perform + * the refresh while other callers receive the current cached credentials. * - *

By default, this is 5 minutes. + *

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

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. + * + * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh */ Builder prefetchTime(Duration prefetchTime); /** - * Configure the amount of time, relative to STS token expiration, that the cached credentials are considered stale and - * must be updated. All threads will block until the value is updated. + * Configure the amount of time, relative to credential expiration, that defines the mandatory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will block all callers until a refresh attempt completes. If the refresh attempt fails, the provider + * returns the cached credentials and will not attempt another refresh until a backoff period has elapsed. + * + *

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. + * + * @param staleTime the duration before expiration that triggers mandatory (blocking) refresh */ Builder staleTime(Duration staleTime); 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/ContainerCredentialsProviderTest.java b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProviderTest.java index 29955af439ca..028e21b77143 100644 --- a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProviderTest.java +++ b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProviderTest.java @@ -140,4 +140,54 @@ private String getSuccessfulBody() { "\"Token\":\"TOKEN_TOKEN_TOKEN\"," + "\"Expiration\":\"3000-05-03T04:55:54Z\"}"; } + + /** + * Tests that when the cache is stale and refresh fails, the provider returns cached credentials + * instead of throwing an exception (ALLOW behavior / static stability). + */ + @Test + public void testRefreshFailureReturnsCachedCredentials_whenCacheIsStale() { + // First call succeeds with credentials that are already expired (stale immediately on next get) + String alreadyExpiredBody = "{\"AccessKeyId\":\"ACCESS_KEY_ID\"," + + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY\"," + + "\"Token\":\"TOKEN_TOKEN_TOKEN\"," + + "\"Expiration\":\"2020-01-01T00:00:00Z\"}"; + + stubFor(get(urlPathEqualTo(CREDENTIALS_PATH)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(alreadyExpiredBody))); + + // First call succeeds (initial fetch always succeeds even if credentials are expired) + AwsCredentials firstCredentials = credentialsProvider.resolveCredentials(); + assertThat(firstCredentials.accessKeyId()).isEqualTo(ACCESS_KEY_ID); + + // Now stub the endpoint to return a 500 error (simulating container metadata endpoint failure) + stubFor(get(urlPathEqualTo(CREDENTIALS_PATH)) + .willReturn(aResponse() + .withStatus(500) + .withBody("Internal Server Error"))); + + // Second call: cache is stale (expiration is in the past), refresh fails with 500, + // but ALLOW behavior should return the cached credentials + AwsCredentials secondCredentials = credentialsProvider.resolveCredentials(); + assertThat(secondCredentials.accessKeyId()).isEqualTo(ACCESS_KEY_ID); + assertThat(secondCredentials.secretAccessKey()).isEqualTo(SECRET_ACCESS_KEY); + } + + /** + * Tests that when no credentials are cached (initial fetch) and the endpoint fails, + * an exception is thrown. + */ + @Test + public void testInitialFetchFailure_throwsException() { + stubFor(get(urlPathEqualTo(CREDENTIALS_PATH)) + .willReturn(aResponse() + .withStatus(500) + .withBody("Internal Server Error"))); + + assertThatThrownBy(credentialsProvider::resolveCredentials) + .isInstanceOf(SdkClientException.class); + } } 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 055967055c25..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 @@ -573,40 +573,80 @@ void resolveCredentials_callsImdsIfCredentialsWithin5MinutesOfExpiration() { assertThat(credentials10SecondsAgo.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY2"); } + @Test + void resolveCredentials_immediateRefreshWhenRemainingLifetimeLessThan5Minutes() { + // When IMDS returns credentials with less than 5 minutes of remaining lifetime, + // the prefetchTime should be set to 'now', triggering a refresh soon after. + // Advance the clock by 2 minutes to account for jitter on the prefetch time. + AdjustableClock clock = new AdjustableClock(); + AwsCredentialsProvider credentialsProvider = credentialsProviderWithClock(clock); + Instant now = Instant.now(); + // Credentials that expire in 3 minutes (less than the 5 minute advisory window) + Instant shortExpiration = now.plus(3, MINUTES); + String shortLivedCredentialsResponse = + "{" + + "\"AccessKeyId\":\"ACCESS_KEY_ID\"," + + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY\"," + + "\"Expiration\":\"" + DateUtils.formatIso8601Date(shortExpiration) + '"' + + "}"; + + String refreshedCredentialsResponse = + "{" + + "\"AccessKeyId\":\"ACCESS_KEY_ID2\"," + + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY2\"," + + "\"Expiration\":\"" + DateUtils.formatIso8601Date(now.plus(6, HOURS)) + '"' + + "}"; + + // Prime cache with short-lived credentials + clock.time = now; + stubSecureCredentialsResponse(aResponse().withBody(shortLivedCredentialsResponse)); + AwsCredentials firstCredentials = credentialsProvider.resolveCredentials(); + assertThat(firstCredentials.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY"); + + // Advance past any jitter on the prefetch time (which was set to 'now'). + // The staleTime is shortExpiration - 1min = now + 2min. + // The jitter window is at most 1 minute (between prefetchTime and 1min before staleTime). + // So advancing by 2 minutes guarantees we are past the jittered prefetch time, triggering refresh. + clock.time = now.plus(2, MINUTES); + stubSecureCredentialsResponse(aResponse().withBody(refreshedCredentialsResponse)); + AwsCredentials secondCredentials = credentialsProvider.resolveCredentials(); + assertThat(secondCredentials.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY2"); + } + @Test void imdsCallFrequencyIsLimited() { - // Requires running the test multiple times to account for refresh jitter - for (int i = 0; i < 10; i++) { - AdjustableClock clock = new AdjustableClock(); - AwsCredentialsProvider credentialsProvider = credentialsProviderWithClock(clock); - Instant now = Instant.now(); - String successfulCredentialsResponse1 = - "{" - + "\"AccessKeyId\":\"ACCESS_KEY_ID\"," - + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY\"," - + "\"Expiration\":\"" + DateUtils.formatIso8601Date(now) + '"' - + "}"; - - String successfulCredentialsResponse2 = - "{" - + "\"AccessKeyId\":\"ACCESS_KEY_ID2\"," - + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY2\"," - + "\"Expiration\":\"" + DateUtils.formatIso8601Date(now.plus(6, HOURS)) + '"' - + "}"; - - // Set the time to 5 minutes before expiration and call IMDS - clock.time = now.minus(5, MINUTES); - stubSecureCredentialsResponse(aResponse().withBody(successfulCredentialsResponse1)); - AwsCredentials credentials5MinutesAgo = credentialsProvider.resolveCredentials(); - - // Set the time to 2 seconds before expiration, and verify that do not call IMDS because it hasn't been 5 minutes yet - clock.time = now.minus(2, SECONDS); - stubSecureCredentialsResponse(aResponse().withBody(successfulCredentialsResponse2)); - AwsCredentials credentials2SecondsAgo = credentialsProvider.resolveCredentials(); - - assertThat(credentials2SecondsAgo).isEqualTo(credentials5MinutesAgo); - assertThat(credentials5MinutesAgo.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY"); - } + // Verify that IMDS is not called again if we haven't reached the prefetch window + AdjustableClock clock = new AdjustableClock(); + AwsCredentialsProvider credentialsProvider = credentialsProviderWithClock(clock); + Instant now = Instant.now(); + Instant expiration = now.plus(6, HOURS); + String successfulCredentialsResponse1 = + "{" + + "\"AccessKeyId\":\"ACCESS_KEY_ID\"," + + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY\"," + + "\"Expiration\":\"" + DateUtils.formatIso8601Date(expiration) + '"' + + "}"; + + String successfulCredentialsResponse2 = + "{" + + "\"AccessKeyId\":\"ACCESS_KEY_ID2\"," + + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY2\"," + + "\"Expiration\":\"" + DateUtils.formatIso8601Date(expiration.plus(6, HOURS)) + '"' + + "}"; + + // Prime the cache at the current time + clock.time = now; + stubSecureCredentialsResponse(aResponse().withBody(successfulCredentialsResponse1)); + AwsCredentials credentialsAtStart = credentialsProvider.resolveCredentials(); + + // Move time forward but still before the prefetch window (60 min before expiry for 6h credentials). + // Since dynamic prefetchTime = expiration - 60min = now + 5h, anything before that should not trigger refresh. + clock.time = now.plus(4, HOURS); + stubSecureCredentialsResponse(aResponse().withBody(successfulCredentialsResponse2)); + AwsCredentials credentialsLater = credentialsProvider.resolveCredentials(); + + assertThat(credentialsLater).isEqualTo(credentialsAtStart); + assertThat(credentialsAtStart.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY"); } @Test @@ -632,7 +672,7 @@ void testErrorWhileCacheIsStale_shouldRecover() { Duration staleTime = Duration.ofMinutes(5); - AwsCredentialsProvider provider = credentialsProviderWithClock(clock, staleTime); + AwsCredentialsProvider provider = credentialsProviderWithClock(clock, staleTime, Duration.ofMinutes(10)); // cache expiration with expiration = 6 hours clock.time = now; @@ -704,10 +744,15 @@ private AwsCredentialsProvider credentialsProviderWithClock(Clock clock) { } private AwsCredentialsProvider credentialsProviderWithClock(Clock clock, Duration staleTime) { + return credentialsProviderWithClock(clock, staleTime, Duration.ofMinutes(5)); + } + + private AwsCredentialsProvider credentialsProviderWithClock(Clock clock, Duration staleTime, Duration prefetchTime) { InstanceProfileCredentialsProvider.BuilderImpl builder = (InstanceProfileCredentialsProvider.BuilderImpl) InstanceProfileCredentialsProvider.builder(); builder.clock(clock); builder.staleTime(staleTime); + builder.prefetchTime(prefetchTime); return builder.build(); } @@ -731,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/ProcessCredentialsProviderTest.java b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProviderTest.java index 5b77907c59fa..e4b3ba321482 100644 --- a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProviderTest.java +++ b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProviderTest.java @@ -161,7 +161,8 @@ void sessionCredentialsCanBeLoaded() { .command(String.format("%s %s %s token=%s exp=%s", scriptLocation, ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, expiration)) - .credentialRefreshThreshold(Duration.ofSeconds(1)) + .staleTime(Duration.ofMillis(500)) + .prefetchTime(Duration.ofSeconds(1)) .build(); AwsCredentials credentials = credentialsProvider.resolveCredentials(); @@ -176,7 +177,8 @@ void sessionCredentialsWithAccountIdCanBeLoaded() { ProcessCredentialsProvider.builder() .command(String.format("%s %s %s token=sessionToken exp=%s acctid=%s", scriptLocation, ACCESS_KEY_ID, SECRET_ACCESS_KEY, expiration, ACCOUNT_ID)) - .credentialRefreshThreshold(Duration.ofSeconds(1)) + .staleTime(Duration.ofMillis(500)) + .prefetchTime(Duration.ofSeconds(1)) .build(); AwsCredentials credentials = credentialsProvider.resolveCredentials(); @@ -191,7 +193,8 @@ void sessionCredentialsWithStaticAccountIdCanBeLoaded() { ProcessCredentialsProvider.builder() .command(String.format("%s %s %s token=sessionToken exp=%s", scriptLocation, ACCESS_KEY_ID, SECRET_ACCESS_KEY, expiration)) - .credentialRefreshThreshold(Duration.ofSeconds(1)) + .staleTime(Duration.ofMillis(500)) + .prefetchTime(Duration.ofSeconds(1)) .staticAccountId("staticAccountId") .sourceChain("v") .build(); @@ -225,7 +228,7 @@ void resultsAreCached() { ProcessCredentialsProvider.builder() .command(String.format("%s %s %s token=%s exp=%s", scriptLocation, ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, - DateUtils.formatIso8601Date(Instant.now().plusSeconds(20)))) + DateUtils.formatIso8601Date(Instant.now().plus(Duration.ofMinutes(30))))) .build(); AwsCredentials request1 = credentialsProvider.resolveCredentials(); @@ -241,7 +244,8 @@ void expirationBufferOverrideIsApplied() { .command(String.format("%s %s %s token=%s exp=%s", scriptLocation, ACCESS_KEY_ID, SECRET_ACCESS_KEY, RANDOM_SESSION_TOKEN, DateUtils.formatIso8601Date(Instant.now().plusSeconds(20)))) - .credentialRefreshThreshold(Duration.ofSeconds(20)) + .staleTime(Duration.ofSeconds(10)) + .prefetchTime(Duration.ofSeconds(20)) .build(); AwsCredentials request1 = credentialsProvider.resolveCredentials(); @@ -250,12 +254,47 @@ void expirationBufferOverrideIsApplied() { assertThat(request1).isNotEqualTo(request2); } + @Test + void defaultPrefetchTime_credentialsWithinFiveMinuteWindow_areRefreshed() { + // Credentials that expire in 30 seconds: staleTime = now+30s - 1min = now-30s (in the past, stale!) + // In STRICT mode, stale credentials force a synchronous refresh on every call + ProcessCredentialsProvider credentialsProvider = + ProcessCredentialsProvider.builder() + .command(String.format("%s %s %s token=%s exp=%s", + scriptLocation, ACCESS_KEY_ID, SECRET_ACCESS_KEY, RANDOM_SESSION_TOKEN, + DateUtils.formatIso8601Date(Instant.now().plusSeconds(30)))) + .build(); + + AwsCredentials request1 = credentialsProvider.resolveCredentials(); + AwsCredentials request2 = credentialsProvider.resolveCredentials(); + + assertThat(request1).isNotEqualTo(request2); + } + + @Test + void defaultPrefetchTime_credentialsFarFromExpiry_areCached() { + // Credentials that expire in 30 minutes: prefetchTime = now+30min - 5min = now+25min (in the future) + // So the cache should NOT refresh + ProcessCredentialsProvider credentialsProvider = + ProcessCredentialsProvider.builder() + .command(String.format("%s %s %s token=%s exp=%s", + scriptLocation, ACCESS_KEY_ID, SECRET_ACCESS_KEY, RANDOM_SESSION_TOKEN, + DateUtils.formatIso8601Date(Instant.now().plus(Duration.ofMinutes(30))))) + .build(); + + AwsCredentials request1 = credentialsProvider.resolveCredentials(); + AwsCredentials request2 = credentialsProvider.resolveCredentials(); + + assertThat(request1).isEqualTo(request2); + } + @Test void processFailed_shouldContainErrorMessage() { ProcessCredentialsProvider credentialsProvider = ProcessCredentialsProvider.builder() .command(errorScriptLocation) - .credentialRefreshThreshold(Duration.ofSeconds(20)) + .staleTime(Duration.ofSeconds(10)) + .prefetchTime(Duration.ofSeconds(20)) .build(); assertThatThrownBy(credentialsProvider::resolveCredentials) @@ -269,7 +308,8 @@ void lackOfExpirationIsCachedForever() { ProcessCredentialsProvider.builder() .command(String.format("%s %s %s token=%s", scriptLocation, ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN)) - .credentialRefreshThreshold(Duration.ofSeconds(20)) + .staleTime(Duration.ofSeconds(10)) + .prefetchTime(Duration.ofSeconds(20)) .build(); AwsCredentials request1 = credentialsProvider.resolveCredentials(); 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..e27c1f7176fd --- /dev/null +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelper.java @@ -0,0 +1,98 @@ +/* + * 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); + } + } + + private static void doInvalidate(SelectedAuthScheme selectedAuthScheme) { + T resolvedIdentity = CompletableFutureUtils.joinLikeSync(selectedAuthScheme.identity()); + IdentityProvider provider = selectedAuthScheme.identityProvider(); + // Invalidation is best-effort and must not block the request/retry path. + // Most CredentialProvider invalidation implementations invalidate synchronously and return instantly + // but handle the future here. + try { + provider.invalidate(resolvedIdentity) + .exceptionally(e -> { + if (e != null) { + LOG.debug(() -> "Failed to invalidate identity provider: " + e.getMessage(), e); + } + return null; + }); + } catch (RuntimeException 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 95f8e51bb153..c36369411ff5 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 @@ -18,12 +18,14 @@ import static software.amazon.awssdk.utils.UserHomeDirectoryUtils.userHomeDirectory; import static software.amazon.awssdk.utils.Validate.notNull; import static software.amazon.awssdk.utils.Validate.paramNotBlank; +import static software.amazon.awssdk.utils.cache.CachedSupplier.StaleValueBehavior.ALLOW; import java.nio.file.Path; import java.nio.file.Paths; 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; @@ -34,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; @@ -43,11 +46,14 @@ import software.amazon.awssdk.services.signin.model.AccessDeniedException; import software.amazon.awssdk.services.signin.model.CreateOAuth2TokenRequest; import software.amazon.awssdk.services.signin.model.CreateOAuth2TokenResponse; +import software.amazon.awssdk.services.signin.model.OAuth2ErrorCode; import software.amazon.awssdk.utils.Logger; import software.amazon.awssdk.utils.SdkAutoCloseable; import software.amazon.awssdk.utils.StringUtils; +import software.amazon.awssdk.utils.Validate; import software.amazon.awssdk.utils.builder.CopyableBuilder; import software.amazon.awssdk.utils.builder.ToCopyableBuilder; +import software.amazon.awssdk.utils.cache.CacheRefreshUtils; import software.amazon.awssdk.utils.cache.CachedSupplier; import software.amazon.awssdk.utils.cache.NonBlocking; import software.amazon.awssdk.utils.cache.RefreshResult; @@ -75,7 +81,6 @@ public final class LoginCredentialsProvider implements private static final String PROVIDER_NAME = BusinessMetricFeatureId.CREDENTIALS_LOGIN.value(); private static final Duration DEFAULT_STALE_TIME = Duration.ofMinutes(1); - private static final Duration DEFAULT_PREFETCH_TIME = Duration.ofMinutes(5); private static final Path DEFAULT_TOKEN_LOCATION = Paths.get(userHomeDirectory(), ".aws", "login", "cache"); private static final String ASYNC_THREAD_NAME = "sdk-login-credentials-provider"; @@ -103,7 +108,11 @@ private LoginCredentialsProvider(BuilderImpl builder) { this.loginSession = paramNotBlank(builder.loginSession, "LoginSession"); this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); - this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); + this.prefetchTime = builder.prefetchTime; + if (this.prefetchTime != null) { + Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, + "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + } this.sourceChain = builder.sourceChain; this.providerName = StringUtils.isEmpty(builder.sourceChain) @@ -120,7 +129,9 @@ private LoginCredentialsProvider(BuilderImpl builder) { this.asyncCredentialUpdateEnabled = builder.asyncCredentialUpdateEnabled; CachedSupplier.Builder cacheBuilder = CachedSupplier.builder(this::updateSigninCredentials) - .cachedValueName(toString()); + .cachedValueName(toString()) + .staleValueBehavior(ALLOW) + .nonRecoverableErrorPredicate(LoginCredentialsProvider::isNonRecoverableError); if (builder.asyncCredentialUpdateEnabled) { cacheBuilder.prefetchStrategy(new NonBlocking(ASYNC_THREAD_NAME)); } @@ -129,21 +140,22 @@ private LoginCredentialsProvider(BuilderImpl builder) { } /** - * Update the expiring session SSO credentials by calling SSO. Invoked by {@link CachedSupplier} when the credentials are - * close to expiring. + * Update the expiring session Login credentials by calling the Signin Service. Invoked by {@link CachedSupplier} when the + * credentials are close to expiring. */ private RefreshResult updateSigninCredentials() { // always re-load token from the disk in case it has been updated elsewhere LoginAccessToken tokenFromDisc = onDiskTokenManager.loadToken().orElseThrow( - () -> SdkClientException.create("Token cache file for login_session `" + loginSession + "` not found. " + () -> new InvalidTokenException("Token cache file for login_session `" + loginSession + "` not found. " + "You must re-authenticate.")); Instant currentExpirationTime = tokenFromDisc.getAccessToken().expirationTime().orElseThrow( - () -> SdkClientException.create("Invalid token expiration time. You must re-authenticate.") + () -> new InvalidTokenException("Invalid token expiration time. You must re-authenticate.") ); + Duration effectivePrefetch = CacheRefreshUtils.computePrefetchWindow(currentExpirationTime, prefetchTime, Instant.now()); if (shouldNotRefresh(currentExpirationTime, staleTime) - && shouldNotRefresh(currentExpirationTime, prefetchTime)) { + && shouldNotRefresh(currentExpirationTime, effectivePrefetch)) { log.debug(() -> "Using access token from disk, current expiration time is : " + currentExpirationTime); AwsCredentials credentials = tokenFromDisc.getAccessToken() .toBuilder() @@ -152,7 +164,7 @@ && shouldNotRefresh(currentExpirationTime, prefetchTime)) { return RefreshResult.builder(credentials) .staleTime(currentExpirationTime.minus(staleTime)) - .prefetchTime(currentExpirationTime.minus(prefetchTime)) + .prefetchTime(currentExpirationTime.minus(effectivePrefetch)) .build(); } @@ -162,8 +174,7 @@ && shouldNotRefresh(currentExpirationTime, prefetchTime)) { private RefreshResult refreshFromSigninService(LoginAccessToken tokenFromDisc) { log.debug(() -> "Credentials are near expiration/expired, refreshing from Signin service."); - try { - SdkPlugin dpopAuthPlugin = DpopAuthPlugin.create(tokenFromDisc.getDpopKey()); + try (SdkPlugin dpopAuthPlugin = DpopAuthPlugin.create(tokenFromDisc.getDpopKey())) { CreateOAuth2TokenRequest refreshRequest = CreateOAuth2TokenRequest .builder() @@ -196,7 +207,8 @@ private RefreshResult refreshFromSigninService(LoginAccessToken return RefreshResult.builder((AwsCredentials) updatedCredentials) .staleTime(newExpiration.minus(staleTime)) - .prefetchTime(newExpiration.minus(prefetchTime)) + .prefetchTime(newExpiration.minus( + CacheRefreshUtils.computePrefetchWindow(newExpiration, prefetchTime, Instant.now()))) .build(); } catch (AccessDeniedException accessDeniedException) { if (accessDeniedException.error() == null) { @@ -205,16 +217,12 @@ private RefreshResult refreshFromSigninService(LoginAccessToken switch (accessDeniedException.error()) { case TOKEN_EXPIRED: - throw SdkClientException.create( - "Your session has expired. Please reauthenticate.", - accessDeniedException); case USER_CREDENTIALS_CHANGED: - throw SdkClientException.create( - "Unable to refresh credentials because of a change in your password. " - + "Please reauthenticate with your new password.", - accessDeniedException - ); + // Let the original AccessDeniedException propagate — the nonRecoverableErrorPredicate + // on CachedSupplier will identify it and bypass static stability. + throw accessDeniedException; case INSUFFICIENT_PERMISSIONS: + // 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.", @@ -228,16 +236,49 @@ private RefreshResult refreshFromSigninService(LoginAccessToken } /** - * The amount of time, relative to session token expiration, that the cached credentials are considered stale and should no - * longer be used. All threads will block until the value is updated. + * Determines whether a given exception represents a non-recoverable refresh failure that should bypass + * static stability. For Login, this includes: + *

    + *
  • Missing or invalid token cache on disk (requires re-authentication)
  • + *
  • An {@link AccessDeniedException} with error code {@link OAuth2ErrorCode#TOKEN_EXPIRED}, + * {@link OAuth2ErrorCode#USER_CREDENTIALS_CHANGED}, or {@link OAuth2ErrorCode#INSUFFICIENT_PERMISSIONS}
  • + *
+ */ + private static boolean isNonRecoverableError(RuntimeException e) { + if (e instanceof InvalidTokenException) { + return true; + } + + AccessDeniedException ade = extractAccessDeniedException(e); + if (ade == null) { + return false; + } + return ade.error() == OAuth2ErrorCode.TOKEN_EXPIRED + || ade.error() == OAuth2ErrorCode.USER_CREDENTIALS_CHANGED + || ade.error() == OAuth2ErrorCode.INSUFFICIENT_PERMISSIONS; + } + + private static AccessDeniedException extractAccessDeniedException(RuntimeException e) { + if (e instanceof AccessDeniedException) { + return (AccessDeniedException) e; + } + if (e.getCause() instanceof AccessDeniedException) { + return (AccessDeniedException) e.getCause(); + } + return 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. */ public Duration staleTime() { return staleTime; } /** - * The amount of time, relative to session token expiration, that the cached credentials are considered close to stale and - * should be updated. + * The amount of time, relative to credential expiration, that defines the advisory refresh window. When credentials are + * within this window, the provider proactively attempts to refresh them. */ public Duration prefetchTime() { return prefetchTime; @@ -255,6 +296,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(); @@ -286,29 +334,52 @@ public interface Builder extends CopyableBuilderRegardless of this setting, callers will block if credentials enter the mandatory refresh window (defined by + * {@link #staleTime(Duration)}). * *

By default, this is enabled. */ Builder asyncCredentialUpdateEnabled(Boolean asyncCredentialUpdateEnabled); /** - * Configure the amount of time, relative to login token expiration, that the cached credentials are considered stale and - * should no longer be used. All threads will block until the value is updated. + * Configure the amount of time, relative to credential expiration, that defines the mandatory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will block all callers until a refresh attempt completes. If the refresh attempt fails, the provider + * returns the cached credentials and will not attempt another refresh until a backoff period has elapsed. + * + *

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. + * + * @param staleTime the duration before expiration that triggers mandatory (blocking) refresh */ Builder staleTime(Duration staleTime); /** - * Configure the amount of time, relative to signin token expiration, that the cached credentials are considered close to - * stale and should be updated. - *

- * Prefetch updates will occur between the specified time and the stale time of the provider. Prefetch updates may be - * asynchronous. See {@link #asyncCredentialUpdateEnabled}. + * Configure the amount of time, relative to credential expiration, that defines the advisory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will attempt to refresh them proactively. If the refresh fails, the provider returns the existing cached + * credentials without error and will not attempt another refresh until a backoff period has elapsed. + * + *

When {@link #asyncCredentialUpdateEnabled(Boolean)} is true, advisory refreshes happen in a background thread + * and callers immediately receive the current cached credentials. When it is false, one caller will block to perform + * the refresh while other callers receive the current cached credentials. * - *

By default, this is 5 minutes. + *

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

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. + * + * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh */ Builder prefetchTime(Duration prefetchTime); @@ -337,6 +408,17 @@ public interface Builder extends CopyableBuilder invalidate(AwsCredentialsIdentity identity) { + return this.credentialsProvider.invalidate(identity); + } + @Override public void close() { IoUtils.closeQuietly(credentialsProvider, null); diff --git a/services/signin/src/test/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProviderTest.java b/services/signin/src/test/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProviderTest.java index 4431bf6f85be..7a9a22515c96 100644 --- a/services/signin/src/test/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProviderTest.java +++ b/services/signin/src/test/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProviderTest.java @@ -26,8 +26,11 @@ import static software.amazon.awssdk.services.signin.auth.internal.DpopTestUtils.verifySignature; import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.UncheckedIOException; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; import java.nio.file.Path; import java.time.Instant; import java.util.ArrayList; @@ -54,6 +57,7 @@ import software.amazon.awssdk.services.signin.internal.LoginAccessToken; import software.amazon.awssdk.services.signin.internal.OnDiskTokenManager; import software.amazon.awssdk.services.signin.model.CreateOAuth2TokenRequest; +import software.amazon.awssdk.services.signin.model.AccessDeniedException; import software.amazon.awssdk.services.signin.model.OAuth2ErrorCode; import software.amazon.awssdk.services.signin.model.SigninException; import software.amazon.awssdk.testutils.service.http.MockSyncHttpClient; @@ -182,7 +186,7 @@ public void resolveCredentials_whenCredentialsExpired_refreshesAndUpdatesCache() @Test public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithGeneric500_raisesException() { - // expired + // expired - no cached value in CachedSupplier yet, so ALLOW still throws on first failure AwsSessionCredentials creds = buildCredentials(Instant.now().minusSeconds(60)); LoginAccessToken token = buildAccessToken(creds); tokenManager.storeToken(token); @@ -195,6 +199,50 @@ public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithGeneri assertThrows(SigninException.class, () -> loginCredentialsProvider.resolveCredentials()); } + @Test + public void resolveCredentials_transientFailureAfterSuccessfulCache_returnsCachedCredentials() { + // First: store token with expired credentials so it triggers refresh from service + AwsSessionCredentials creds = buildCredentials(Instant.now().minusSeconds(600)); + LoginAccessToken token = buildAccessToken(creds); + tokenManager.storeToken(token); + + // First response: successful refresh with short-lived credentials (expires in 30s) + // staleTime will be now+30s - 1min = now-30s (already stale), so next get() will refresh again + String shortLivedJsonBody = + "{\"accessToken\":" + + "{\"accessKeyId\":\"new-akid\"," + + "\"secretAccessKey\":\"new-skid\"," + + "\"sessionToken\":\"new-session-token\"}," + + "\"tokenType\":\"aws_sigv4\"," + + "\"expiresIn\":30," + + "\"refreshToken\":\"new-refresh-token\"}"; + + HttpExecuteResponse successResponse = HttpExecuteResponse + .builder() + .response(SdkHttpResponse.builder().statusCode(200).build()) + .responseBody(AbortableInputStream.create( + new ByteArrayInputStream(shortLivedJsonBody.getBytes(StandardCharsets.UTF_8)))) + .build(); + + // Second response: transient 500 error + HttpExecuteResponse failureResponse = HttpExecuteResponse + .builder() + .response(SdkHttpResponse.builder().statusCode(500).build()) + .build(); + + mockHttpClient.stubResponses(successResponse, failureResponse); + + // First call: succeeds and populates the CachedSupplier cache + AwsCredentials firstResolve = loginCredentialsProvider.resolveCredentials(); + assertEquals("new-akid", firstResolve.accessKeyId()); + + // Second call: the cached value is already stale (30s expiry - 1min staleTime < now), + // so CachedSupplier tries to refresh, gets 500, and with ALLOW behavior returns cached value + AwsCredentials secondResolve = loginCredentialsProvider.resolveCredentials(); + assertEquals("new-akid", secondResolve.accessKeyId()); + assertEquals("new-skid", secondResolve.secretAccessKey()); + } + @Test public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithTokenExpired_raisesException() { // expired @@ -203,8 +251,9 @@ public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithTokenE tokenManager.storeToken(token); stubAccessDeniedException(OAuth2ErrorCode.TOKEN_EXPIRED); - SdkClientException e = assertThrows(SdkClientException.class, () -> loginCredentialsProvider.resolveCredentials()); - assertTrue(e.getMessage().contains("Your session has expired")); + AccessDeniedException e = assertThrows(AccessDeniedException.class, + () -> loginCredentialsProvider.resolveCredentials()); + assertNotNull(e); } @Test @@ -215,8 +264,9 @@ public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithUserEx tokenManager.storeToken(token); stubAccessDeniedException(OAuth2ErrorCode.USER_CREDENTIALS_CHANGED); - SdkClientException e = assertThrows(SdkClientException.class, () -> loginCredentialsProvider.resolveCredentials()); - assertTrue(e.getMessage().contains("change in your password")); + AccessDeniedException e = assertThrows(AccessDeniedException.class, + () -> loginCredentialsProvider.resolveCredentials()); + assertNotNull(e); } @Test @@ -231,6 +281,132 @@ public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithInsuff assertTrue(e.getMessage().contains("insufficient permissions")); } + @Test + public void resolveCredentials_tokenCacheMissingAfterSuccessfulCache_throwsAndBypassesStaticStability() throws Exception { + // Build a provider without async updates so refresh is synchronous + LoginCredentialsProvider syncProvider = LoginCredentialsProvider + .builder() + .loginSession(LOGIN_SESSION_ID) + .signinClient(signinClient) + .tokenCacheLocation(tempDir) + .asyncCredentialUpdateEnabled(false) + .build(); + + // First: store token with expired credentials so it triggers refresh from service + AwsSessionCredentials creds = buildCredentials(Instant.now().minusSeconds(600)); + LoginAccessToken token = buildAccessToken(creds); + tokenManager.storeToken(token); + + // First response: successful refresh with short-lived credentials (expires in 30s) + String shortLivedJsonBody = + "{\"accessToken\":" + + "{\"accessKeyId\":\"new-akid\"," + + "\"secretAccessKey\":\"new-skid\"," + + "\"sessionToken\":\"new-session-token\"}," + + "\"tokenType\":\"aws_sigv4\"," + + "\"expiresIn\":30," + + "\"refreshToken\":\"new-refresh-token\"}"; + + HttpExecuteResponse successResponse = HttpExecuteResponse + .builder() + .response(SdkHttpResponse.builder().statusCode(200).build()) + .responseBody(AbortableInputStream.create( + new ByteArrayInputStream(shortLivedJsonBody.getBytes(StandardCharsets.UTF_8)))) + .build(); + + mockHttpClient.stubResponses(successResponse); + + // First call: succeeds and populates the CachedSupplier cache + AwsCredentials firstResolve = syncProvider.resolveCredentials(); + assertEquals("new-akid", firstResolve.accessKeyId()); + + // Now delete all token cache files from the temp directory to simulate token being removed + Files.list(tempDir) + .filter(p -> p.toString().endsWith(".json")) + .forEach(p -> { + try { + Files.delete(p); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + + // Second call: the cached value is stale (handleFetchedSuccess extended it with jitter, but + // we wait for it to expire). Since the stale time was extended 1-10 minutes into the future, + // we instead rely on the prefetch window triggering a synchronous OneCallerBlocks refresh. + // With OneCallerBlocks, the refresh happens inline and the InvalidTokenException propagates. + // Give a brief pause and then call again - the prefetch time should already be in the past + // since the credentials expired in 30s and handleFetchedSuccess sets prefetch = stale time. + // Actually, with the extended stale time, the value enters prefetch window immediately. + // With OneCallerBlocks, the failing refresh throws through to the caller. + SdkClientException e = assertThrows(SdkClientException.class, + () -> syncProvider.resolveCredentials()); + assertTrue(e.getMessage().contains("not found")); + syncProvider.close(); + } + + @Test + public void resolveCredentials_tokenMalformedAfterSuccessfulCache_staticStabilityReturnsCachedCredentials() + throws Exception { + // First: store token with expired credentials so it triggers refresh from service + AwsSessionCredentials creds = buildCredentials(Instant.now().minusSeconds(600)); + LoginAccessToken token = buildAccessToken(creds); + tokenManager.storeToken(token); + + // First response: successful refresh with short-lived credentials (expires in 30s) + String shortLivedJsonBody = + "{\"accessToken\":" + + "{\"accessKeyId\":\"new-akid\"," + + "\"secretAccessKey\":\"new-skid\"," + + "\"sessionToken\":\"new-session-token\"}," + + "\"tokenType\":\"aws_sigv4\"," + + "\"expiresIn\":30," + + "\"refreshToken\":\"new-refresh-token\"}"; + + HttpExecuteResponse successResponse = HttpExecuteResponse + .builder() + .response(SdkHttpResponse.builder().statusCode(200).build()) + .responseBody(AbortableInputStream.create( + new ByteArrayInputStream(shortLivedJsonBody.getBytes(StandardCharsets.UTF_8)))) + .build(); + + mockHttpClient.stubResponses(successResponse); + + // First call: succeeds and populates the CachedSupplier cache + AwsCredentials firstResolve = loginCredentialsProvider.resolveCredentials(); + assertEquals("new-akid", firstResolve.accessKeyId()); + + // Now overwrite the token file with one that is missing the expiresAt field. + // This simulates a corrupted or malformed token file on disk. + String malformedTokenJson = + "{\"accessToken\":" + + "{\"accessKeyId\":\"akid\"," + + "\"secretAccessKey\":\"skid\"," + + "\"sessionToken\":\"sessionToken\"," + + "\"accountId\":\"123456789012\"}," + + "\"clientId\":\"client-123\"," + + "\"dpopKey\":\"" + VALID_TEST_PEM.replace("\n", "\\n") + "\"," + + "\"refreshToken\":\"refresh-token\"," + + "\"tokenType\":\"aws_sigv4\"," + + "\"identityToken\":\"id-token\"}"; + Files.list(tempDir) + .filter(p -> p.toString().endsWith(".json")) + .forEach(p -> { + try { + Files.write(p, malformedTokenJson.getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + + // Second call: the cached value is stale, CachedSupplier refreshes, but token has no expiresAt. + // OnDiskTokenManager throws a plain SdkClientException (not InvalidTokenException), so static + // stability kicks in and returns the cached credentials rather than throwing. + AwsCredentials secondResolve = loginCredentialsProvider.resolveCredentials(); + assertEquals("new-akid", secondResolve.accessKeyId()); + assertEquals("new-skid", secondResolve.secretAccessKey()); + } + private static void verifyResolvedCredentialsAreUpdated(AwsCredentials resolvedCredentials) { assertEquals("new-akid", resolvedCredentials.accessKeyId()); assertEquals("new-skid", resolvedCredentials.secretAccessKey()); 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 42465940b7ca..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 @@ -15,25 +15,31 @@ package software.amazon.awssdk.services.sso.auth; +import static software.amazon.awssdk.utils.Validate.isTrue; import static software.amazon.awssdk.utils.Validate.notNull; +import static software.amazon.awssdk.utils.cache.CachedSupplier.StaleValueBehavior.ALLOW; 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; import software.amazon.awssdk.services.sso.model.RoleCredentials; +import software.amazon.awssdk.services.sso.model.UnauthorizedException; import software.amazon.awssdk.utils.SdkAutoCloseable; import software.amazon.awssdk.utils.StringUtils; import software.amazon.awssdk.utils.builder.CopyableBuilder; import software.amazon.awssdk.utils.builder.ToCopyableBuilder; +import software.amazon.awssdk.utils.cache.CacheRefreshUtils; import software.amazon.awssdk.utils.cache.CachedSupplier; import software.amazon.awssdk.utils.cache.NonBlocking; import software.amazon.awssdk.utils.cache.RefreshResult; @@ -56,7 +62,6 @@ public final class SsoCredentialsProvider implements AwsCredentialsProvider, Sdk private static final String PROVIDER_NAME = BusinessMetricFeatureId.CREDENTIALS_SSO.value(); private static final Duration DEFAULT_STALE_TIME = Duration.ofMinutes(1); - private static final Duration DEFAULT_PREFETCH_TIME = Duration.ofMinutes(5); private static final String ASYNC_THREAD_NAME = "sdk-sso-credentials-provider"; @@ -80,7 +85,11 @@ private SsoCredentialsProvider(BuilderImpl builder) { this.getRoleCredentialsRequestSupplier = builder.getRoleCredentialsRequestSupplier; this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); - this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); + this.prefetchTime = builder.prefetchTime; + if (this.prefetchTime != null) { + isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, + "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + } this.sourceChain = builder.sourceChain; this.providerName = StringUtils.isEmpty(builder.sourceChain) @@ -90,7 +99,10 @@ private SsoCredentialsProvider(BuilderImpl builder) { this.asyncCredentialUpdateEnabled = builder.asyncCredentialUpdateEnabled; CachedSupplier.Builder cacheBuilder = CachedSupplier.builder(this::updateSsoCredentials) - .cachedValueName(toString()); + .cachedValueName(toString()) + .staleValueBehavior(ALLOW) + .nonRecoverableErrorPredicate( + e -> e instanceof ExpiredTokenException || e instanceof UnauthorizedException); if (builder.asyncCredentialUpdateEnabled) { cacheBuilder.prefetchStrategy(new NonBlocking(ASYNC_THREAD_NAME)); } @@ -106,15 +118,19 @@ private RefreshResult updateSsoCredentials() { SessionCredentialsHolder credentials = getUpdatedCredentials(ssoClient); Instant actualTokenExpiration = credentials.sessionCredentialsExpiration(); + Instant now = Instant.now(); + Duration effectivePrefetchWindow = CacheRefreshUtils.computePrefetchWindow(actualTokenExpiration, prefetchTime, now); + return RefreshResult.builder(credentials) .staleTime(actualTokenExpiration.minus(staleTime)) - .prefetchTime(actualTokenExpiration.minus(prefetchTime)) + .prefetchTime(actualTokenExpiration.minus(effectivePrefetchWindow)) .build(); } private SessionCredentialsHolder getUpdatedCredentials(SsoClient ssoClient) { GetRoleCredentialsRequest request = getRoleCredentialsRequestSupplier.get(); notNull(request, "GetRoleCredentialsRequest can't be null."); + RoleCredentials roleCredentials = ssoClient.getRoleCredentials(request).roleCredentials(); AwsSessionCredentials sessionCredentials = AwsSessionCredentials.builder() .accessKeyId(roleCredentials.accessKeyId()) @@ -127,16 +143,16 @@ private SessionCredentialsHolder getUpdatedCredentials(SsoClient ssoClient) { } /** - * The amount of time, relative to session token expiration, that the cached credentials are considered stale and - * should no longer be used. All threads will block until the value is updated. + * 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. */ public Duration staleTime() { return staleTime; } /** - * The amount of time, relative to session token expiration, that the cached credentials are considered close to stale - * and should be updated. + * The amount of time, relative to credential expiration, that defines the advisory refresh window. When credentials are + * within this window, the provider proactively attempts to refresh them. */ public Duration prefetchTime() { return prefetchTime; @@ -154,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(); @@ -176,30 +199,52 @@ public interface Builder extends CopyableBuilderRegardless of this setting, callers will block if credentials enter the mandatory refresh window (defined by + * {@link #staleTime(Duration)}). * *

By default, this is disabled.

*/ Builder asyncCredentialUpdateEnabled(Boolean asyncCredentialUpdateEnabled); /** - * Configure the amount of time, relative to SSO session token expiration, that the cached credentials are considered - * stale and should no longer be used. All threads will block until the value is updated. + * Configure the amount of time, relative to credential expiration, that defines the mandatory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will block all callers until a refresh attempt completes. If the refresh attempt fails, the provider + * returns the cached credentials and will not attempt another refresh until a backoff period has elapsed. + * + *

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.

+ * + * @param staleTime the duration before expiration that triggers mandatory (blocking) refresh */ Builder staleTime(Duration staleTime); /** - * Configure the amount of time, relative to SSO session token expiration, that the cached credentials are considered - * close to stale and should be updated. + * Configure the amount of time, relative to credential expiration, that defines the advisory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will attempt to refresh them proactively. If the refresh fails, the provider returns the existing cached + * credentials without error and will not attempt another refresh until a backoff period has elapsed. + * + *

When {@link #asyncCredentialUpdateEnabled(Boolean)} is true, advisory refreshes happen in a background thread + * and callers immediately receive the current cached credentials. When it is false, one caller will block to perform + * the refresh while other callers receive the current cached credentials. + * + *

This value must be greater than or equal to {@link #staleTime(Duration)}. Setting this equal to + * {@code staleTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking). * - * Prefetch updates will occur between the specified time and the stale time of the provider. Prefetch updates may be - * asynchronous. See {@link #asyncCredentialUpdateEnabled}. + *

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.

* - *

By default, this is 5 minutes.

+ * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh */ Builder prefetchTime(Duration prefetchTime); 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 d7be6cdd852c..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 @@ -16,6 +16,7 @@ package software.amazon.awssdk.services.sso.auth; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -26,12 +27,16 @@ 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; import software.amazon.awssdk.services.sso.model.RoleCredentials; +import software.amazon.awssdk.services.sso.model.UnauthorizedException; /** * Validates the functionality of {@link SsoCredentialsProvider}. @@ -88,6 +93,189 @@ public void distantExpiringCredentialsUpdatedInBackground_OverridePrefetchAndSta callClient(verify(ssoClient, times(2)), Mockito.any()); } + @Test + public void refreshFailureReturnsCachedCredentials_staticStability() { + ssoClient = mock(SsoClient.class); + RoleCredentials credentials = RoleCredentials.builder() + .accessKeyId("a") + .secretAccessKey("b") + .sessionToken("c") + .expiration(Instant.now().minus(Duration.ofSeconds(5)).toEpochMilli()) + .build(); + + Supplier supplier = getRequestSupplier(); + GetRoleCredentialsResponse response = getResponse(credentials); + + // First call succeeds, second call fails with transient error + when(ssoClient.getRoleCredentials(supplier.get())) + .thenReturn(response) + .thenThrow(SdkClientException.create("SSO service unavailable")); + + try (SsoCredentialsProvider credentialsProvider = SsoCredentialsProvider.builder() + .refreshRequest(supplier) + .ssoClient(ssoClient) + .build()) { + // First call succeeds and caches credentials + AwsSessionCredentials firstResult = (AwsSessionCredentials) credentialsProvider.resolveCredentials(); + assertThat(firstResult.accessKeyId()).isEqualTo("a"); + + // Second call should return cached credentials because ALLOW is set + AwsSessionCredentials secondResult = (AwsSessionCredentials) credentialsProvider.resolveCredentials(); + assertThat(secondResult.accessKeyId()).isEqualTo("a"); + assertThat(secondResult.secretAccessKey()).isEqualTo("b"); + assertThat(secondResult.sessionToken()).isEqualTo("c"); + } + } + + @Test + public void unauthorizedException_bypassesStaticStability() { + ssoClient = mock(SsoClient.class); + RoleCredentials credentials = RoleCredentials.builder() + .accessKeyId("a") + .secretAccessKey("b") + .sessionToken("c") + .expiration(Instant.now().minus(Duration.ofSeconds(5)).toEpochMilli()) + .build(); + + Supplier supplier = getRequestSupplier(); + GetRoleCredentialsResponse response = getResponse(credentials); + + UnauthorizedException unauthorizedException = (UnauthorizedException) UnauthorizedException.builder() + .message("Token is expired") + .build(); + + // First call succeeds, second call fails with UnauthorizedException + when(ssoClient.getRoleCredentials(supplier.get())) + .thenReturn(response) + .thenThrow(unauthorizedException); + + try (SsoCredentialsProvider credentialsProvider = SsoCredentialsProvider.builder() + .refreshRequest(supplier) + .ssoClient(ssoClient) + .build()) { + // First call succeeds and caches credentials + AwsSessionCredentials firstResult = (AwsSessionCredentials) credentialsProvider.resolveCredentials(); + assertThat(firstResult.accessKeyId()).isEqualTo("a"); + + // Second call should throw UnauthorizedException directly (bypasses static stability) + assertThatThrownBy(credentialsProvider::resolveCredentials) + .isInstanceOf(UnauthorizedException.class) + .hasMessageContaining("Token is expired"); + } + } + + @Test + public void expiredTokenException_bypassesStaticStability() { + ssoClient = mock(SsoClient.class); + + ExpiredTokenException expiredTokenException = (ExpiredTokenException) ExpiredTokenException.builder() + .message("The SSO session associated with this profile has expired") + .build(); + + // Request supplier throws ExpiredTokenException (client-side token expiry) + Supplier expiredSupplier = () -> { + throw expiredTokenException; + }; + + try (SsoCredentialsProvider credentialsProvider = SsoCredentialsProvider.builder() + .refreshRequest(expiredSupplier) + .ssoClient(ssoClient) + .build()) { + // Should throw ExpiredTokenException directly (bypasses static stability) + assertThatThrownBy(credentialsProvider::resolveCredentials) + .isInstanceOf(ExpiredTokenException.class) + .hasMessageContaining("The SSO session associated with this profile has expired"); + } + } + + @Test + public void noCachedCredentials_anyFailure_throwsImmediately() { + ssoClient = mock(SsoClient.class); + + Supplier supplier = getRequestSupplier(); + + // First call fails with a transient error — no cached credentials exist + when(ssoClient.getRoleCredentials(supplier.get())) + .thenThrow(SdkClientException.create("SSO service unavailable")); + + try (SsoCredentialsProvider credentialsProvider = SsoCredentialsProvider.builder() + .refreshRequest(supplier) + .ssoClient(ssoClient) + .build()) { + // Should throw immediately since no cached credentials exist + assertThatThrownBy(credentialsProvider::resolveCredentials) + .isInstanceOf(SdkClientException.class) + .hasMessageContaining("SSO service unavailable"); + } + } + + + + @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() { @@ -129,7 +317,7 @@ private void callClientWithCredentialsProvider(Instant credentialsExpirationDate assertThat(credentialsProvider.prefetchTime()).as("prefetch time").isEqualTo(Duration.ofMinutes(4)); } else { assertThat(credentialsProvider.staleTime()).as("stale time").isEqualTo(Duration.ofMinutes(1)); - assertThat(credentialsProvider.prefetchTime()).as("prefetch time").isEqualTo(Duration.ofMinutes(5)); + assertThat(credentialsProvider.prefetchTime()).as("prefetch time").isNull(); } for (int i = 0; i < numTimesInvokeCredentialsProvider; ++i) { 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 74ebc39c664e..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; @@ -32,6 +34,7 @@ import software.amazon.awssdk.utils.Validate; import software.amazon.awssdk.utils.builder.CopyableBuilder; import software.amazon.awssdk.utils.builder.ToCopyableBuilder; +import software.amazon.awssdk.utils.cache.CacheRefreshUtils; import software.amazon.awssdk.utils.cache.CachedSupplier; import software.amazon.awssdk.utils.cache.NonBlocking; import software.amazon.awssdk.utils.cache.RefreshResult; @@ -53,7 +56,6 @@ public abstract class StsCredentialsProvider implements AwsCredentialsProvider, private static final Logger log = Logger.loggerFor(StsCredentialsProvider.class); private static final Duration DEFAULT_STALE_TIME = Duration.ofMinutes(1); - private static final Duration DEFAULT_PREFETCH_TIME = Duration.ofMinutes(5); /** * The STS client that should be used for periodically updating the session credentials. @@ -73,12 +75,17 @@ public abstract class StsCredentialsProvider implements AwsCredentialsProvider, this.stsClient = Validate.notNull(builder.stsClient, "STS client must not be null."); this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); - this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); + this.prefetchTime = builder.prefetchTime; + if (this.prefetchTime != null) { + Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, + "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + } this.asyncCredentialUpdateEnabled = builder.asyncCredentialUpdateEnabled; CachedSupplier.Builder cacheBuilder = CachedSupplier.builder(this::updateSessionCredentials) - .cachedValueName(toString()); + .cachedValueName(toString()) + .staleValueBehavior(CachedSupplier.StaleValueBehavior.ALLOW); if (builder.asyncCredentialUpdateEnabled) { cacheBuilder.prefetchStrategy(new NonBlocking(asyncThreadName)); } @@ -95,9 +102,12 @@ private RefreshResult updateSessionCredentials() { credentials.expirationTime() .orElseThrow(() -> new IllegalStateException("Sourced credentials have no expiration value")); + Instant now = Instant.now(); + Duration effectivePrefetchWindow = CacheRefreshUtils.computePrefetchWindow(actualTokenExpiration, prefetchTime, now); + return RefreshResult.builder(credentials) .staleTime(actualTokenExpiration.minus(staleTime)) - .prefetchTime(actualTokenExpiration.minus(prefetchTime)) + .prefetchTime(actualTokenExpiration.minus(effectivePrefetchWindow)) .build(); } @@ -115,17 +125,24 @@ 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 STS token expiration, that the cached credentials are considered stale and - * should no longer be used. All threads will block until the value is updated. + * 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. */ public Duration staleTime() { return staleTime; } /** - * The amount of time, relative to STS token expiration, that the cached credentials are considered close to stale - * and should be updated. + * The amount of time, relative to credential expiration, that defines the advisory refresh window. When credentials are + * within this window, the provider proactively attempts to refresh them. */ public Duration prefetchTime() { return prefetchTime; @@ -183,9 +200,13 @@ public B stsClient(StsClient stsClient) { } /** - * Configure whether the provider should fetch credentials asynchronously in the background. If this is true, - * threads are less likely to block when credentials are loaded, but additional resources are used to maintain - * the provider. + * Configure whether the provider should fetch credentials asynchronously in the background. When enabled, a + * dedicated thread performs credential refreshes during the advisory refresh window (defined by + * {@link #prefetchTime(Duration)}), so that callers are less likely to block waiting for credentials. Additional + * resources (a thread) are used to maintain the provider. + * + *

Regardless of this setting, callers will block if credentials enter the mandatory refresh window (defined by + * {@link #staleTime(Duration)}). * *

By default, this is disabled.

*/ @@ -196,10 +217,17 @@ public B asyncCredentialUpdateEnabled(Boolean asyncCredentialUpdateEnabled) { } /** - * Configure the amount of time, relative to STS token expiration, that the cached credentials are considered - * stale and must be updated. All threads will block until the value is updated. + * Configure the amount of time, relative to credential expiration, that defines the mandatory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will block all callers until a refresh attempt completes. If the refresh attempt fails, the provider + * returns the cached credentials and will not attempt another refresh until a backoff period has elapsed. + * + *

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.

+ * + * @param staleTime the duration before expiration that triggers mandatory (blocking) refresh */ @SuppressWarnings("unchecked") public B staleTime(Duration staleTime) { @@ -208,13 +236,24 @@ public B staleTime(Duration staleTime) { } /** - * Configure the amount of time, relative to STS token expiration, that the cached credentials are considered - * close to stale and should be updated. + * Configure the amount of time, relative to credential expiration, that defines the advisory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will attempt to refresh them proactively. If the refresh fails, the provider returns the existing cached + * credentials without error and will not attempt another refresh until a backoff period has elapsed. + * + *

When {@link #asyncCredentialUpdateEnabled(Boolean)} is true, advisory refreshes happen in a background thread + * and callers immediately receive the current cached credentials. When it is false, one caller will block to perform + * the refresh while other callers receive the current cached credentials. + * + *

This value must be greater than or equal to {@link #staleTime(Duration)}. Setting this equal to + * {@code staleTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking). * - * Prefetch updates will occur between the specified time and the stale time of the provider. Prefetch updates may be - * asynchronous. See {@link #asyncCredentialUpdateEnabled}. + *

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.

* - *

By default, this is 5 minutes.

+ * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh */ @SuppressWarnings("unchecked") public B prefetchTime(Duration prefetchTime) { 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 8c054aa97e1a..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 @@ -16,6 +16,7 @@ package software.amazon.awssdk.services.sts.auth; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -27,7 +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; @@ -101,6 +106,110 @@ public void distantExpiringCredentialsUpdatedInBackground_OverridePrefetchAndSta protected abstract String providerName(); + @Test + public void refreshFailureReturnsCachedCredentials_staticStability() { + // First call returns valid credentials that are already expired (to force a refresh on next call) + Credentials validCredentials = Credentials.builder() + .accessKeyId("a") + .secretAccessKey("b") + .sessionToken("c") + .expiration(Instant.now().minus(Duration.ofSeconds(5))) + .build(); + RequestT request = getRequest(); + ResponseT response = getResponse(validCredentials); + + // First call succeeds, second call fails + when(callClient(stsClient, request)) + .thenReturn(response) + .thenThrow(SdkClientException.create("STS service unavailable")); + + StsCredentialsProvider.BaseBuilder credentialsProviderBuilder = + createCredentialsProviderBuilder(request); + + try (StsCredentialsProvider credentialsProvider = credentialsProviderBuilder.stsClient(stsClient).build()) { + // First call should succeed and cache credentials + AwsCredentials firstResult = credentialsProvider.resolveCredentials(); + assertThat(firstResult).isInstanceOf(AwsSessionCredentials.class); + assertThat(((AwsSessionCredentials) firstResult).accessKeyId()).isEqualTo("a"); + + // Second call should return cached credentials instead of throwing + // because StaleValueBehavior.ALLOW is now set + AwsCredentials secondResult = credentialsProvider.resolveCredentials(); + assertThat(secondResult).isInstanceOf(AwsSessionCredentials.class); + assertThat(((AwsSessionCredentials) secondResult).accessKeyId()).isEqualTo("a"); + assertThat(((AwsSessionCredentials) secondResult).secretAccessKey()).isEqualTo("b"); + assertThat(((AwsSessionCredentials) secondResult).sessionToken()).isEqualTo("c"); + } + } + + @Test + public void initialFetchFailureThrowsException_noCachedCredentials() { + RequestT request = getRequest(); + + // The very first call to STS fails — no credentials have ever been cached + when(callClient(stsClient, request)) + .thenThrow(SdkClientException.create("STS service unavailable")); + + StsCredentialsProvider.BaseBuilder credentialsProviderBuilder = + createCredentialsProviderBuilder(request); + + try (StsCredentialsProvider credentialsProvider = credentialsProviderBuilder.stsClient(stsClient).build()) { + // Should throw because there are no cached credentials to fall back on + assertThatThrownBy(credentialsProvider::resolveCredentials) + .isInstanceOf(SdkClientException.class) + .hasMessageContaining("STS service unavailable"); + } + } + + @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(); @@ -123,7 +232,7 @@ public void callClientWithCredentialsProvider(Instant credentialsExpirationDate, } else { //validate that the default values are used assertThat(credentialsProvider.staleTime()).as("stale time").isEqualTo(Duration.ofMinutes(1)); - assertThat(credentialsProvider.prefetchTime()).as("prefetch time").isEqualTo(Duration.ofMinutes(5)); + assertThat(credentialsProvider.prefetchTime()).as("prefetch time").isNull(); } for (int i = 0; i < numTimesInvokeCredentialsProvider; ++i) { diff --git a/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsWebIdentityTokenCredentialProviderTest.java b/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsWebIdentityTokenCredentialProviderTest.java index 058eff163eff..82673b870363 100644 --- a/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsWebIdentityTokenCredentialProviderTest.java +++ b/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsWebIdentityTokenCredentialProviderTest.java @@ -183,7 +183,7 @@ void defaultTiming_usesStandardValues() { .build(); try { - assertThat(provider.prefetchTime()).isEqualTo(Duration.ofMinutes(5)); + assertThat(provider.prefetchTime()).isNull(); assertThat(provider.staleTime()).isEqualTo(Duration.ofMinutes(1)); } finally { provider.close(); diff --git a/services/sts/src/test/java/software/amazon/awssdk/services/sts/internal/StsWebIdentityCredentialsProviderFactoryTest.java b/services/sts/src/test/java/software/amazon/awssdk/services/sts/internal/StsWebIdentityCredentialsProviderFactoryTest.java index 904a2ff2c0ff..ee5174919a4b 100644 --- a/services/sts/src/test/java/software/amazon/awssdk/services/sts/internal/StsWebIdentityCredentialsProviderFactoryTest.java +++ b/services/sts/src/test/java/software/amazon/awssdk/services/sts/internal/StsWebIdentityCredentialsProviderFactoryTest.java @@ -39,8 +39,8 @@ void stsWebIdentityCredentialsProviderFactory_withWebIdentityTokenCredentialProp AwsCredentialsProvider provider = factory.create( WebIdentityTokenCredentialProperties.builder() .asyncCredentialUpdateEnabled(true) - .prefetchTime(Duration.ofMinutes(5)) - .staleTime(Duration.ofMinutes(15)) + .prefetchTime(Duration.ofMinutes(15)) + .staleTime(Duration.ofMinutes(5)) .roleArn("role-arn") .webIdentityTokenFile(Paths.get("/path/to/file")) .roleSessionName("session-name") diff --git a/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/AuthErrorInvalidationFunctionalTest.java b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/AuthErrorInvalidationFunctionalTest.java new file mode 100644 index 000000000000..bf18fc2a5e5f --- /dev/null +++ b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/AuthErrorInvalidationFunctionalTest.java @@ -0,0 +1,225 @@ +/* + * 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.services.retry; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URI; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.http.AbortableInputStream; +import software.amazon.awssdk.http.HttpExecuteResponse; +import software.amazon.awssdk.http.SdkHttpRequest; +import software.amazon.awssdk.http.SdkHttpResponse; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; +import software.amazon.awssdk.identity.spi.ResolveIdentityRequest; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.protocolrestjson.ProtocolRestJsonClient; +import software.amazon.awssdk.testutils.service.http.MockSyncHttpClient; +import software.amazon.awssdk.utils.StringInputStream; + +/** + * Integration test verifying the end-to-end flow: + * service returns ExpiredToken → credentials invalidated → next call uses fresh credentials. + */ +public class AuthErrorInvalidationFunctionalTest { + + /** + * End-to-end: ExpiredToken → invalidation is triggered → next call uses fresh credentials. + * + * ExpiredToken exceptions are not retryable, so the first API call fails immediately. + * However, invalidation is still triggered on the provider, ensuring the next API call + * resolves fresh credentials. + * + * Scenario: + * 1. First API call: signed with "old-key", service returns ExpiredToken (not retryable) + * 2. SDK calls invalidate() on the provider (switching it to return "new-key") + * 3. First call fails with ExpiredToken exception + * 4. Second API call: resolves fresh identity "new-key", succeeds + */ + @Test + public void expiredToken_invalidatesCredentials_nextCallUsesFreshCredentials() { + MockSyncHttpClient mockHttpClient = new MockSyncHttpClient(); + InvalidationTrackingCredentialsProvider credentialsProvider = + new InvalidationTrackingCredentialsProvider("old-key", "old-secret", "new-key", "new-secret"); + + try (ProtocolRestJsonClient client = ProtocolRestJsonClient.builder() + .credentialsProvider(credentialsProvider) + .region(Region.US_EAST_1) + .endpointOverride(URI.create("http://localhost")) + .httpClient(mockHttpClient) + .build()) { + + // First API call: ExpiredToken (not retryable — fails immediately) + mockHttpClient.stubResponses(expiredTokenResponse()); + + assertThatThrownBy(() -> client.allTypes()) + .isInstanceOf(AwsServiceException.class) + .satisfies(e -> assertThat(((AwsServiceException) e).awsErrorDetails().errorCode()) + .isEqualTo("ExpiredToken")); + + // Verify invalidate was called during the first API call + assertThat(credentialsProvider.invalidateCallCount()).isEqualTo(1); + + // Verify first call used "old-key" + List firstCallRequests = mockHttpClient.getRequests(); + assertThat(firstCallRequests).hasSize(1); + assertRequestUsedAccessKey(firstCallRequests.get(0), "old-key"); + + // Now make a second API call — this should resolve fresh credentials + mockHttpClient.reset(); + mockHttpClient.stubResponses(successResponse()); + + client.allTypes(); + + // Second call should use new credentials (provider was invalidated) + List secondCallRequests = mockHttpClient.getRequests(); + assertThat(secondCallRequests).hasSize(1); + assertRequestUsedAccessKey(secondCallRequests.get(0), "new-key"); + } + } + + /** + * Verify that AccessDenied does NOT trigger invalidation and the exception propagates. + */ + @Test + public void accessDenied_doesNotInvalidateCredentials() { + MockSyncHttpClient mockHttpClient = new MockSyncHttpClient(); + InvalidationTrackingCredentialsProvider credentialsProvider = + new InvalidationTrackingCredentialsProvider("my-key", "my-secret", "new-key", "new-secret"); + + try (ProtocolRestJsonClient client = ProtocolRestJsonClient.builder() + .credentialsProvider(credentialsProvider) + .region(Region.US_EAST_1) + .endpointOverride(URI.create("http://localhost")) + .httpClient(mockHttpClient) + .build()) { + + mockHttpClient.stubResponses(accessDeniedResponse()); + + assertThatThrownBy(() -> client.allTypes()) + .isInstanceOf(AwsServiceException.class) + .satisfies(e -> assertThat(((AwsServiceException) e).awsErrorDetails().errorCode()) + .isEqualTo("AccessDenied")); + + // Verify invalidate was NOT called + assertThat(credentialsProvider.invalidateCallCount()).isEqualTo(0); + } + } + + // --- Helper methods --- + + private void assertRequestUsedAccessKey(SdkHttpRequest request, String expectedAccessKeyId) { + assertThat(request.firstMatchingHeader("Authorization")) + .isPresent() + .hasValueSatisfying(authHeader -> + assertThat(authHeader).contains("Credential=" + expectedAccessKeyId + "/") + ); + } + + private HttpExecuteResponse expiredTokenResponse() { + String errorBody = "{\"__type\":\"ExpiredTokenException\",\"message\":\"The security token included in the request " + + "is expired\"}"; + return HttpExecuteResponse.builder() + .response(SdkHttpResponse.builder() + .statusCode(403) + .putHeader("x-amzn-ErrorType", "ExpiredToken") + .putHeader("content-length", + String.valueOf(errorBody.length())) + .build()) + .responseBody(AbortableInputStream.create(new StringInputStream(errorBody))) + .build(); + } + + private HttpExecuteResponse accessDeniedResponse() { + String errorBody = "{\"__type\":\"AccessDeniedException\",\"message\":\"User is not authorized\"}"; + return HttpExecuteResponse.builder() + .response(SdkHttpResponse.builder() + .statusCode(403) + .putHeader("x-amzn-ErrorType", "AccessDenied") + .putHeader("content-length", + String.valueOf(errorBody.length())) + .build()) + .responseBody(AbortableInputStream.create(new StringInputStream(errorBody))) + .build(); + } + + private HttpExecuteResponse successResponse() { + String body = "{}"; + return HttpExecuteResponse.builder() + .response(SdkHttpResponse.builder() + .statusCode(200) + .putHeader("content-length", + String.valueOf(body.length())) + .build()) + .responseBody(AbortableInputStream.create(new StringInputStream(body))) + .build(); + } + + // --- Test doubles --- + + /** + * A credentials provider that tracks invalidation calls and switches to fresh credentials + * after invalidation is triggered. This simulates the behavior of a caching provider + * (like IMDS or Container provider) that serves stale credentials until invalidated. + */ + private static class InvalidationTrackingCredentialsProvider implements AwsCredentialsProvider { + private final AwsBasicCredentials oldCredentials; + private final AwsBasicCredentials newCredentials; + private final AtomicInteger invalidateCount = new AtomicInteger(0); + private volatile boolean invalidated = false; + + InvalidationTrackingCredentialsProvider(String oldAccessKey, String oldSecretKey, + String newAccessKey, String newSecretKey) { + this.oldCredentials = AwsBasicCredentials.create(oldAccessKey, oldSecretKey); + this.newCredentials = AwsBasicCredentials.create(newAccessKey, newSecretKey); + } + + @Override + public AwsCredentials resolveCredentials() { + return invalidated ? newCredentials : oldCredentials; + } + + @Override + public CompletableFuture resolveIdentity(ResolveIdentityRequest request) { + AwsCredentials creds = resolveCredentials(); + return CompletableFuture.completedFuture(creds); + } + + @Override + public Class identityType() { + return AwsCredentialsIdentity.class; + } + + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + invalidateCount.incrementAndGet(); + invalidated = true; + return CompletableFuture.completedFuture(null); + } + + int invalidateCallCount() { + return invalidateCount.get(); + } + } +} 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 new file mode 100644 index 000000000000..f7921f7a2ad2 --- /dev/null +++ b/utils/src/main/java/software/amazon/awssdk/utils/cache/CacheRefreshUtils.java @@ -0,0 +1,72 @@ +/* + * 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.utils.cache; + +import java.time.Duration; +import java.time.Instant; +import software.amazon.awssdk.annotations.SdkProtectedApi; + +/** + * Utility methods for credential cache refresh timing computation. + */ +@SdkProtectedApi +public final class CacheRefreshUtils { + + private static final Duration WINDOW_SHORT = Duration.ofMinutes(5); + private static final Duration WINDOW_MEDIUM = Duration.ofMinutes(15); + private static final Duration WINDOW_LONG = Duration.ofMinutes(60); + + private static final long THRESHOLD_MEDIUM_MINUTES = 20; + private static final long THRESHOLD_LONG_MINUTES = 90; + + private CacheRefreshUtils() { + } + + /** + * Compute the advisory refresh window (prefetch time) for a credential. If {@code prefetchTime} is non-null + * (i.e., explicitly configured by the user), it is returned directly. Otherwise, the window is computed + * 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: + *

    + *
  • remaining lifetime < 20 minutes → 5 minute window
  • + *
  • 20 minutes ≤ remaining lifetime < 90 minutes → 15 minute window
  • + *
  • remaining lifetime ≥ 90 minutes → 60 minute window
  • + *
+ * + * @param expiration the credential's expiration time + * @param prefetchTime the explicitly configured prefetch window, or {@code null} to compute dynamically + * @param now the current time + * @return the Duration to use as the advisory refresh window + */ + public static Duration computePrefetchWindow(Instant expiration, Duration prefetchTime, Instant now) { + if (prefetchTime != null) { + return prefetchTime; + } + + Duration remainingLifetime = Duration.between(now, expiration); + long remainingMinutes = remainingLifetime.toMinutes(); + + if (remainingMinutes < THRESHOLD_MEDIUM_MINUTES) { + return WINDOW_SHORT; + } else if (remainingMinutes < THRESHOLD_LONG_MINUTES) { + return WINDOW_MEDIUM; + } else { + return WINDOW_LONG; + } + } +} 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 e8ecc4d741d1..3700ecbc0142 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 @@ -23,9 +23,9 @@ import java.util.Random; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Predicate; import java.util.function.Supplier; import software.amazon.awssdk.annotations.SdkProtectedApi; import software.amazon.awssdk.annotations.SdkTestInternalApi; @@ -54,6 +54,16 @@ public class CachedSupplier implements Supplier, SdkAutoCloseable { */ private static final Duration BLOCKING_REFRESH_MAX_WAIT = Duration.ofSeconds(5); + /** + * Minimum backoff duration when a refresh fails (inclusive). + */ + private static final Duration STATIC_STABILITY_BACKOFF_MIN = Duration.ofMinutes(5); + + /** + * Maximum backoff duration when a refresh fails (inclusive). + */ + private static final Duration STATIC_STABILITY_BACKOFF_MAX = Duration.ofMinutes(10); + /** * Used as a primitive form of rate limiting for the speed of our refreshes. This will make sure that the backing supplier has @@ -83,11 +93,6 @@ public class CachedSupplier implements Supplier, SdkAutoCloseable { */ private final Clock clock; - /** - * The number of consecutive failures encountered when updating a stale value. - */ - private final AtomicInteger consecutiveStaleRetrievalFailures = new AtomicInteger(0); - /** * The name to include with each log message, to differentiate caches. */ @@ -108,6 +113,21 @@ public class CachedSupplier implements Supplier, SdkAutoCloseable { */ private final Random jitterRandom = new Random(); + /** + * 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 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"); Validate.notNull(builder.jitterEnabled, "builder.jitterEnabled"); @@ -117,6 +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.nonRecoverableErrorPredicate = builder.nonRecoverableErrorPredicate; } /** @@ -131,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(); } @@ -141,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. + * + *

If the lock is not immediately available, this method returns without invalidating. + * A held lock means either a refresh or another invalidation is already in progress. + * In either case it is safe to skip: a refresh will replace the cached value shortly, + * and a concurrent invalidation will mark it stale. + * + * @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) { + if (!refreshLock.tryLock()) { + log.debug(() -> "(" + cachedValueName + ") Refresh lock held by another thread during invalidation; " + + "skipping because either a refresh or another invalidation is already in progress."); + 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(); + } + } + /** * Determines whether the value in this cache is stale, and all threads should block and wait for an updated value. */ @@ -177,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}. */ @@ -229,8 +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) { - consecutiveStaleRetrievalFailures.set(0); - + this.nextAllowedRefreshTime = null; // Clear backoff gate on success Instant now = clock.instant(); if (now.isBefore(fetch.staleTime())) { @@ -240,12 +323,12 @@ private RefreshResult handleFetchedSuccess(RefreshResult fetch) { switch (staleValueBehavior) { case STRICT: Instant newStale = now.plusSeconds(1); - log.warn(() -> "(" + cachedValueName + ") Retrieved value expiration is in the past (" + fetch.staleTime() + + log.debug(() -> "(" + cachedValueName + ") Retrieved value expiration is in the past (" + fetch.staleTime() + "). Using expiration of " + newStale); return fetch.toBuilder().staleTime(newStale).build(); // Refresh again in 1 second case ALLOW: Instant newStaleTime = jitterTime(now, Duration.ofMinutes(1), Duration.ofMinutes(10)); - log.warn(() -> "(" + cachedValueName + ") Cached value expiration has been extended to " + newStaleTime + + log.debug(() -> "(" + cachedValueName + ") Cached value expiration has been extended to " + newStaleTime + " because the downstream service returned a time in the past: " + fetch.staleTime()); return fetch.toBuilder() @@ -269,25 +352,50 @@ private RefreshResult handleFetchFailure(RuntimeException e) { Instant now = clock.instant(); if (!now.isBefore(currentCachedValue.staleTime())) { - int numFailures = consecutiveStaleRetrievalFailures.incrementAndGet(); - switch (staleValueBehavior) { case STRICT: throw e; case ALLOW: - Instant newStaleTime = jitterTime(now, Duration.ofMillis(1), maxStaleFailureJitter(numFailures)); - log.warn(() -> "(" + cachedValueName + ") Cached value expiration has been extended to " + - newStaleTime + " because calling the downstream service failed (consecutive failures: " + - numFailures + ").", e); - - return currentCachedValue.toBuilder() - .staleTime(newStaleTime) - .build(); + // Non-recoverable errors bypass static stability + if (nonRecoverableErrorPredicate != null && nonRecoverableErrorPredicate.test(e)) { + throw e; + } + + // 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)); + this.nextAllowedRefreshTime = now.plusSeconds(backoffSeconds); + + log.debug(() -> "(" + cachedValueName + ") Credential refresh failed: " + e.getMessage() + + ". Will retry after " + backoffSeconds + " seconds.", e); + + return currentCachedValue; // Return unchanged — staleTime/prefetchTime untouched default: throw new IllegalStateException("Unknown stale-value-behavior: " + staleValueBehavior); } } + // Not yet stale — we're in the prefetch window. Handle failure based on mode. + if (staleValueBehavior == StaleValueBehavior.ALLOW) { + if (nonRecoverableErrorPredicate != null && nonRecoverableErrorPredicate.test(e)) { + throw e; + } + + // 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)); + this.nextAllowedRefreshTime = now.plusSeconds(backoffSeconds); + + log.debug(() -> "(" + cachedValueName + ") Credential refresh failed: " + e.getMessage() + + ". Will retry after " + backoffSeconds + " seconds.", e); + + return currentCachedValue; // Return unchanged — staleTime/prefetchTime untouched + } + return currentCachedValue; } @@ -333,6 +441,12 @@ private Duration maxPrefetchJitter(RefreshResult result) { return timeBetweenPrefetchAndStale; } + private Instant jitterTime(Instant time, Duration jitterStart, Duration jitterEnd) { + long jitterRange = jitterEnd.minus(jitterStart).toMillis(); + long jitterAmount = Math.abs(jitterRandom.nextLong() % jitterRange); + return time.plus(jitterStart).plusMillis(jitterAmount); + } + private Duration maxStaleFailureJitter(int numFailures) { // prevent cycling back through low values if (numFailures > 63) { @@ -350,12 +464,6 @@ protected Duration maxStaleFailureJitterTest(int numFailures) { return maxStaleFailureJitter(numFailures); } - private Instant jitterTime(Instant time, Duration jitterStart, Duration jitterEnd) { - long jitterRange = jitterEnd.minus(jitterStart).toMillis(); - long jitterAmount = Math.abs(jitterRandom.nextLong() % jitterRange); - return time.plus(jitterStart).plusMillis(jitterAmount); - } - /** * Free any resources consumed by the prefetch strategy this supplier is using. */ @@ -374,6 +482,7 @@ public static final class Builder { private StaleValueBehavior staleValueBehavior = StaleValueBehavior.STRICT; private Clock clock = Clock.systemUTC(); private String cachedValueName = "unknown"; + private Predicate nonRecoverableErrorPredicate; private Builder(Supplier> supplier) { this.supplier = supplier; @@ -413,6 +522,23 @@ public Builder cachedValueName(String cachedValueName) { return this; } + /** + * Configure a predicate that determines whether an exception represents a non-recoverable refresh failure + * that should bypass static stability. When the predicate returns {@code true} for a given exception, + * the exception will be re-thrown immediately without extending the cached value's expiration. + * + *

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). + * + *

By default, no exceptions are considered non-recoverable (all failures trigger static stability + * backoff when {@link StaleValueBehavior#ALLOW} is configured). + */ + public Builder nonRecoverableErrorPredicate(Predicate nonRecoverableErrorPredicate) { + this.nonRecoverableErrorPredicate = nonRecoverableErrorPredicate; + return this; + } + /** * Configure the clock used for this cached supplier. Configurable for testing. */ @@ -488,8 +614,14 @@ public enum StaleValueBehavior { STRICT, /** - * Allow stale values to be returned from the cache. Value retrieval will never fail, as long as the cache has - * succeeded when calling the underlying supplier at least once. + * 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#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 non-recoverable. */ ALLOW } diff --git a/utils/src/test/java/software/amazon/awssdk/utils/cache/CacheRefreshUtilsTest.java b/utils/src/test/java/software/amazon/awssdk/utils/cache/CacheRefreshUtilsTest.java new file mode 100644 index 000000000000..adb6ae4bb98e --- /dev/null +++ b/utils/src/test/java/software/amazon/awssdk/utils/cache/CacheRefreshUtilsTest.java @@ -0,0 +1,119 @@ +/* + * 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.utils.cache; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link CacheRefreshUtils}. + */ +public class CacheRefreshUtilsTest { + + private static final Instant NOW = Instant.parse("2024-01-01T00:00:00Z"); + + @Test + public void remainingLifetimeUnder20Minutes_returns5MinuteWindow() { + // 19 minutes remaining + Instant expiration = NOW.plus(Duration.ofMinutes(19)); + Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(5)); + } + + @Test + public void remainingLifetimeExactly0_returns5MinuteWindow() { + // 0 minutes remaining (already expired) + Duration window = CacheRefreshUtils.computePrefetchWindow(NOW, null, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(5)); + } + + @Test + public void remainingLifetime5Minutes_returns5MinuteWindow() { + Instant expiration = NOW.plus(Duration.ofMinutes(5)); + Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(5)); + } + + @Test + public void remainingLifetimeExactly20Minutes_returns15MinuteWindow() { + Instant expiration = NOW.plus(Duration.ofMinutes(20)); + Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(15)); + } + + @Test + public void remainingLifetime45Minutes_returns15MinuteWindow() { + Instant expiration = NOW.plus(Duration.ofMinutes(45)); + Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(15)); + } + + @Test + public void remainingLifetime89Minutes_returns15MinuteWindow() { + Instant expiration = NOW.plus(Duration.ofMinutes(89)); + Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(15)); + } + + @Test + public void remainingLifetimeExactly90Minutes_returns60MinuteWindow() { + Instant expiration = NOW.plus(Duration.ofMinutes(90)); + Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(60)); + } + + @Test + public void remainingLifetime6Hours_returns60MinuteWindow() { + Instant expiration = NOW.plus(Duration.ofHours(6)); + Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(60)); + } + + @Test + public void remainingLifetime12Hours_returns60MinuteWindow() { + Instant expiration = NOW.plus(Duration.ofHours(12)); + Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(60)); + } + + @Test + public void remainingLifetimeNegative_returns5MinuteWindow() { + // Expiration is in the past + Instant expiration = NOW.minus(Duration.ofMinutes(5)); + Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(5)); + } + + @Test + public void explicitPrefetchTime_returnsExplicitValue() { + Instant expiration = NOW.plus(Duration.ofHours(6)); + Duration explicitPrefetch = Duration.ofMinutes(30); + Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, explicitPrefetch, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(30)); + } + + @Test + public void explicitPrefetchTime_ignoresRemainingLifetime() { + // Even with short remaining lifetime, explicit value is used + Instant expiration = NOW.plus(Duration.ofMinutes(10)); + Duration explicitPrefetch = Duration.ofMinutes(60); + Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, explicitPrefetch, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(60)); + } +} 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 159e2d69b6e9..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; @@ -364,25 +366,228 @@ public void throwIsHiddenIfValueIsStaleInAllowMode() throws InterruptedException } @Test - public void maxStaleFailureJitter_shouldNotReturnNegativeOrCycleLowValues() { - CachedSupplier supplier = CachedSupplier.builder(() -> RefreshResult.builder("v") - .staleTime(Instant.MAX) - .build()) - .build(); - - for (int i = 1; i <= 70; i++) { - Duration jitter = supplier.maxStaleFailureJitterTest(i); - assertThat(jitter) - .as("numFailures=%d: jitter must be positive", i) - .isPositive(); - - if (i > 64) { - assertThat(jitter) - .isEqualTo(Duration.ofSeconds(10)); + public void allowMode_returnsCachedValueOnNonCacheInvalidatingFailure() throws InterruptedException { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + Instant now = Instant.now(); + clock.time = now; + + // Initial successful fetch + supplier.set(RefreshResult.builder("cached-creds") + .staleTime(now.plusSeconds(60)) + .prefetchTime(now.plusSeconds(30)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Advance past stale time + clock.time = now.plusSeconds(61); + supplier.set(new RuntimeException("service unavailable")); + + // Should return cached value instead of throwing + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + } + } + + @Test + public void allowMode_cacheInvalidatingError_isRethrown() throws InterruptedException { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .nonRecoverableErrorPredicate( + e -> e instanceof CacheInvalidatingRuntimeException) + .clock(clock) + .jitterEnabled(false) + .build()) { + Instant now = Instant.now(); + clock.time = now; + + // Initial successful fetch + supplier.set(RefreshResult.builder("cached-creds") + .staleTime(now.plusSeconds(60)) + .prefetchTime(now.plusSeconds(30)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Advance past stale time and throw cache-invalidating error + clock.time = now.plusSeconds(61); + CacheInvalidatingRuntimeException invalidatingError = + new CacheInvalidatingRuntimeException("token expired"); + supplier.set(invalidatingError); + + // Should re-throw even though cached value exists + assertThatThrownBy(cachedSupplier::get).isEqualTo(invalidatingError); + } + } + + @Test + public void allowMode_backoffIsInExpectedRange() throws InterruptedException { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + + // Run multiple iterations to verify backoff range + for (int i = 0; i < 50; i++) { + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + supplier.set(RefreshResult.builder("cached-creds") + .staleTime(now.plusSeconds(60)) + .prefetchTime(now.plusSeconds(30)) + .build()); + cachedSupplier.get(); + + // Advance past stale time and trigger failure + clock.time = now.plusSeconds(61); + supplier.set(new RuntimeException("service unavailable")); + cachedSupplier.get(); + + // 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 end - should still be rate limited + clock.time = minBackoffEnd.minusSeconds(1); + supplier.set(RefreshResult.builder("new-creds") + .staleTime(Instant.MAX) + .prefetchTime(Instant.MAX) + .build()); + // Rate limited: returns cached value without contacting source + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // 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_setsBackoffGate() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + // Initial successful fetch with prefetch in the future, stale much later + supplier.set(RefreshResult.builder("cached-creds") + .staleTime(now.plusSeconds(3600)) + .prefetchTime(now.plusSeconds(60)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Advance past prefetch time but before stale time + clock.time = now.plusSeconds(61); + supplier.set(new RuntimeException("service unavailable")); + + // 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 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 rate limit is active, so this should still return cached + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + } + } + + @Test + public void allowMode_prefetchWindowFailure_preservesStaleTime() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + // Initial successful fetch: stale at +3600s (1 hour), prefetch at +60s + Instant originalStaleTime = now.plusSeconds(3600); + supplier.set(RefreshResult.builder("cached-creds") + .staleTime(originalStaleTime) + .prefetchTime(now.plusSeconds(60)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Advance past prefetch time but well before stale time + clock.time = now.plusSeconds(61); + supplier.set(new RuntimeException("service unavailable")); + + // Trigger failure during prefetch window + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // 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()); + // Backoff elapsed, prefetchTime (60s) is in the past, so prefetch is triggered and succeeds + assertThat(cachedSupplier.get()).isEqualTo("refreshed-creds"); + } + } + + @Test + public void allowMode_prefetchWindowFailure_cacheInvalidatingError_isRethrown() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .nonRecoverableErrorPredicate( + e -> e instanceof CacheInvalidatingRuntimeException) + .clock(clock) + .jitterEnabled(false) + .build()) { + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; - supplier.close(); + // Initial successful fetch with prefetch in the future, stale much later + supplier.set(RefreshResult.builder("cached-creds") + .staleTime(now.plusSeconds(3600)) + .prefetchTime(now.plusSeconds(60)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Advance past prefetch time but before stale time + clock.time = now.plusSeconds(61); + CacheInvalidatingRuntimeException invalidatingError = + new CacheInvalidatingRuntimeException("token expired"); + supplier.set(invalidatingError); + + // Should re-throw cache-invalidating error even in prefetch window + assertThatThrownBy(cachedSupplier::get).isEqualTo(invalidatingError); + } + } + + /** + * A RuntimeException that represents a cache-invalidating error for testing. + */ + private static class CacheInvalidatingRuntimeException extends RuntimeException { + CacheInvalidatingRuntimeException(String message) { + super(message); + } } @Test @@ -665,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(); + } + } }