"
+```
+
+The wire-level example enables certificate hostname verification before sending
+the IAM token.
+
+## Security
+
+The generated token is a bearer credential. Keep it out of logs and connect to the
+cache over TLS.
+
+## License
+
+Apache-2.0. See [LICENSE](LICENSE).
diff --git a/java/examples/pom.xml b/java/examples/pom.xml
new file mode 100644
index 0000000..05c6ae2
--- /dev/null
+++ b/java/examples/pom.xml
@@ -0,0 +1,51 @@
+
+
+
+ 4.0.0
+
+ software.amazon.elasticache
+ developer-toolkit-elasticache-examples
+ 0.1.0-SNAPSHOT
+
+
+ UTF-8
+ 8
+ 7.7.0.RELEASE
+
+
+
+
+ software.amazon.elasticache
+ developer-toolkit-elasticache
+ 0.1.0-SNAPSHOT
+
+
+ io.lettuce
+ lettuce-core
+ ${lettuce.version}
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.14.1
+
+ ${maven.compiler.release}
+
+
+
+ org.codehaus.mojo
+ exec-maven-plugin
+ 3.5.0
+
+
+
+
diff --git a/java/examples/src/main/java/software/amazon/elasticache/examples/ConnectServerlessCache.java b/java/examples/src/main/java/software/amazon/elasticache/examples/ConnectServerlessCache.java
new file mode 100644
index 0000000..207a528
--- /dev/null
+++ b/java/examples/src/main/java/software/amazon/elasticache/examples/ConnectServerlessCache.java
@@ -0,0 +1,127 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package software.amazon.elasticache.examples;
+
+import java.io.BufferedInputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import javax.net.ssl.SSLParameters;
+import javax.net.ssl.SSLSocket;
+import javax.net.ssl.SSLSocketFactory;
+
+import software.amazon.awssdk.regions.Region;
+import software.amazon.elasticache.auth.ElastiCacheIamAuthTokenProvider;
+
+/**
+ * Connects to an ElastiCache serverless cache over TLS using IAM authentication.
+ *
+ * This example uses the Redis serialization protocol directly so the toolkit's example
+ * does not require or endorse a particular Redis or Valkey client. Applications should
+ * normally use their preferred client library and supply {@link
+ * ElastiCacheIamAuthTokenProvider#getUserId()} and {@link
+ * ElastiCacheIamAuthTokenProvider#getToken()} when authenticating or reconnecting.
+ */
+public final class ConnectServerlessCache {
+ private static final int DEFAULT_PORT = 6379;
+
+ private ConnectServerlessCache() {}
+
+ /**
+ * Runs the connection example.
+ *
+ * @param args cache name, IAM user ID, endpoint, region, and optional port
+ * @throws IOException if the TLS connection or Redis protocol exchange fails
+ */
+ public static void main(String[] args) throws IOException {
+ if (args.length < 4 || args.length > 5) {
+ throw new IllegalArgumentException(
+ "Usage: ConnectServerlessCache "
+ + " [port]");
+ }
+
+ String cacheName = args[0];
+ String userId = args[1];
+ String endpoint = args[2];
+ Region region = Region.of(args[3]);
+ int port = args.length == 5 ? Integer.parseInt(args[4]) : DEFAULT_PORT;
+
+ ElastiCacheIamAuthTokenProvider auth = ElastiCacheIamAuthTokenProvider.builder()
+ .serverlessCacheName(cacheName)
+ .userId(userId)
+ .region(region)
+ .build();
+
+ SSLSocketFactory socketFactory = (SSLSocketFactory) SSLSocketFactory.getDefault();
+ try (SSLSocket socket = (SSLSocket) socketFactory.createSocket(endpoint, port);
+ BufferedInputStream input = new BufferedInputStream(socket.getInputStream());
+ OutputStream output = socket.getOutputStream()) {
+ enableHostnameVerification(socket);
+ socket.startHandshake();
+
+ writeCommand(output, "AUTH", auth.getUserId(), auth.getToken());
+ expectSimpleResponse(input, "OK");
+
+ writeCommand(output, "PING");
+ expectSimpleResponse(input, "PONG");
+ }
+
+ System.out.println("IAM authentication succeeded; PING returned PONG.");
+ }
+
+ static void enableHostnameVerification(SSLSocket socket) {
+ SSLParameters sslParameters = socket.getSSLParameters();
+ sslParameters.setEndpointIdentificationAlgorithm("HTTPS");
+ socket.setSSLParameters(sslParameters);
+ }
+
+ private static void writeCommand(OutputStream output, String... arguments) throws IOException {
+ writeAscii(output, "*" + arguments.length + "\r\n");
+ for (String argument : arguments) {
+ byte[] value = argument.getBytes(StandardCharsets.UTF_8);
+ writeAscii(output, "$" + value.length + "\r\n");
+ output.write(value);
+ writeAscii(output, "\r\n");
+ }
+ output.flush();
+ }
+
+ private static void expectSimpleResponse(BufferedInputStream input, String expected)
+ throws IOException {
+ int responseType = input.read();
+ if (responseType == -1) {
+ throw new IOException("ElastiCache closed the connection without a response");
+ }
+
+ String response = readLine(input);
+ if (responseType == '-') {
+ throw new IOException("ElastiCache returned an error: " + response);
+ }
+ if (responseType != '+' || !expected.equals(response)) {
+ throw new IOException("Unexpected ElastiCache response");
+ }
+ }
+
+ private static String readLine(BufferedInputStream input) throws IOException {
+ StringBuilder line = new StringBuilder();
+ int previous = -1;
+ while (true) {
+ int current = input.read();
+ if (current == -1) {
+ throw new IOException("ElastiCache closed the connection mid-response");
+ }
+ if (previous == '\r' && current == '\n') {
+ line.setLength(line.length() - 1);
+ return line.toString();
+ }
+ line.append((char) current);
+ previous = current;
+ }
+ }
+
+ private static void writeAscii(OutputStream output, String value) throws IOException {
+ output.write(value.getBytes(StandardCharsets.US_ASCII));
+ }
+}
diff --git a/java/examples/src/main/java/software/amazon/elasticache/examples/ConnectWithLettuce.java b/java/examples/src/main/java/software/amazon/elasticache/examples/ConnectWithLettuce.java
new file mode 100644
index 0000000..9438bef
--- /dev/null
+++ b/java/examples/src/main/java/software/amazon/elasticache/examples/ConnectWithLettuce.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package software.amazon.elasticache.examples;
+
+import io.lettuce.core.RedisClient;
+import io.lettuce.core.RedisCredentials;
+import io.lettuce.core.RedisCredentialsProvider;
+import io.lettuce.core.RedisURI;
+import io.lettuce.core.api.StatefulRedisConnection;
+import reactor.core.publisher.Mono;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.elasticache.auth.ElastiCacheIamAuthTokenProvider;
+
+/**
+ * Connects to an ElastiCache serverless cache with Lettuce and IAM authentication.
+ */
+public final class ConnectWithLettuce {
+ private static final int DEFAULT_PORT = 6379;
+
+ private ConnectWithLettuce() {}
+
+ /**
+ * Runs the Lettuce connection example.
+ *
+ * @param args cache name, IAM user ID, endpoint, region, and optional port
+ */
+ public static void main(String[] args) {
+ if (args.length < 4 || args.length > 5) {
+ throw new IllegalArgumentException(
+ "Usage: ConnectWithLettuce "
+ + " [port]");
+ }
+
+ String cacheName = args[0];
+ String userId = args[1];
+ String endpoint = args[2];
+ Region region = Region.of(args[3]);
+ int port = args.length == 5 ? Integer.parseInt(args[4]) : DEFAULT_PORT;
+
+ ElastiCacheIamAuthTokenProvider auth = ElastiCacheIamAuthTokenProvider.builder()
+ .serverlessCacheName(cacheName)
+ .userId(userId)
+ .region(region)
+ .build();
+
+ RedisCredentialsProvider credentialsProvider = new RedisCredentialsProvider() {
+ @Override
+ public Mono resolveCredentials() {
+ return Mono.fromSupplier(
+ () -> RedisCredentials.just(auth.getUserId(), auth.getToken()));
+ }
+ };
+ RedisURI redisUri = RedisURI.Builder.redis(endpoint, port)
+ .withSsl(true)
+ .withAuthentication(credentialsProvider)
+ .build();
+
+ RedisClient client = RedisClient.create(redisUri);
+ StatefulRedisConnection connection = null;
+ try {
+ connection = client.connect();
+ System.out.println("IAM authentication succeeded; PING returned "
+ + connection.sync().ping() + ".");
+ } finally {
+ if (connection != null) {
+ connection.close();
+ }
+ client.shutdown();
+ }
+ }
+}
diff --git a/java/integration-tests/pom.xml b/java/integration-tests/pom.xml
new file mode 100644
index 0000000..a6ea3a0
--- /dev/null
+++ b/java/integration-tests/pom.xml
@@ -0,0 +1,61 @@
+
+
+
+ 4.0.0
+
+ software.amazon.elasticache
+ developer-toolkit-elasticache-integration-tests
+ 0.1.0-SNAPSHOT
+
+
+ UTF-8
+ 8
+ 5.13.4
+
+
+
+
+ software.amazon.elasticache
+ developer-toolkit-elasticache
+ 0.1.0-SNAPSHOT
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ ${junit.version}
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ ${junit.version}
+ test
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.14.1
+
+ ${maven.compiler.release}
+
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+ 3.4.2
+
+ true
+
+
+
+
+
diff --git a/java/integration-tests/src/test/java/software/amazon/elasticache/integration/PublicApiTest.java b/java/integration-tests/src/test/java/software/amazon/elasticache/integration/PublicApiTest.java
new file mode 100644
index 0000000..09397e2
--- /dev/null
+++ b/java/integration-tests/src/test/java/software/amazon/elasticache/integration/PublicApiTest.java
@@ -0,0 +1,26 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package software.amazon.elasticache.integration;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import software.amazon.elasticache.auth.ConfigurationException;
+import software.amazon.elasticache.auth.ElastiCacheIamAuthTokenProvider;
+import software.amazon.elasticache.auth.InvalidParameterException;
+import software.amazon.elasticache.auth.ToolkitInputException;
+
+class PublicApiTest {
+ @Test
+ void installedArtifactExposesPublicApi() {
+ assertNotNull(ElastiCacheIamAuthTokenProvider.builder());
+ assertTrue(ToolkitInputException.class.isAssignableFrom(ConfigurationException.class));
+ assertTrue(ToolkitInputException.class.isAssignableFrom(InvalidParameterException.class));
+ assertFalse(
+ InvalidParameterException.class.isAssignableFrom(ConfigurationException.class));
+ }
+}
diff --git a/java/pom.xml b/java/pom.xml
new file mode 100644
index 0000000..353bf6d
--- /dev/null
+++ b/java/pom.xml
@@ -0,0 +1,181 @@
+
+
+
+ 4.0.0
+
+ software.amazon.elasticache
+ developer-toolkit-elasticache
+ 0.1.0-SNAPSHOT
+ Developer Toolkit for Amazon ElastiCache
+ Developer toolkit for Amazon ElastiCache
+ https://github.com/aws/developer-toolkit-elasticache
+
+
+
+ Apache License, Version 2.0
+ https://www.apache.org/licenses/LICENSE-2.0.txt
+ repo
+
+
+
+
+ UTF-8
+ 8
+ ["8","11","17","21"]
+ 2.54.10
+ 5.13.4
+
+
+
+
+
+ software.amazon.awssdk
+ bom
+ ${aws.sdk.version}
+ pom
+ import
+
+
+
+
+
+
+ software.amazon.awssdk
+ auth
+
+
+ software.amazon.awssdk
+ sdk-core
+
+
+ software.amazon.awssdk
+ http-client-spi
+
+
+ software.amazon.awssdk
+ http-auth-spi
+
+
+ software.amazon.awssdk
+ identity-spi
+
+
+ software.amazon.awssdk
+ http-auth-aws
+
+
+ software.amazon.awssdk
+ regions
+
+
+
+ org.junit.jupiter
+ junit-jupiter-api
+ ${junit.version}
+ test
+
+
+ org.junit.jupiter
+ junit-jupiter-engine
+ ${junit.version}
+ test
+
+
+
+
+
+
+ ${project.basedir}
+ META-INF
+
+ LICENSE
+ NOTICE
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.14.1
+
+ ${maven.compiler.release}
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+ 3.5.4
+
+ false
+
+
+
+ org.apache.maven.plugins
+ maven-jar-plugin
+ 3.4.2
+
+
+
+ software.amazon.elasticache.auth
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-source-plugin
+ 3.3.1
+
+
+ attach-sources
+
+ jar-no-fork
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-javadoc-plugin
+ 3.11.2
+
+
+ attach-javadocs
+
+ jar
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-dependency-plugin
+ 3.7.0
+
+
+ analyze-dependencies
+ verify
+
+ analyze-only
+
+
+ true
+
+
+
+ org.junit.jupiter:junit-jupiter-engine
+
+
+
+
+
+
+
+
+
diff --git a/java/src/main/java/software/amazon/elasticache/auth/ConfigurationException.java b/java/src/main/java/software/amazon/elasticache/auth/ConfigurationException.java
new file mode 100644
index 0000000..bc46be1
--- /dev/null
+++ b/java/src/main/java/software/amazon/elasticache/auth/ConfigurationException.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package software.amazon.elasticache.auth;
+
+/**
+ * Indicates that the AWS environment is missing configuration required to sign a token.
+ */
+public final class ConfigurationException extends ToolkitInputException {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Creates a configuration exception.
+ *
+ * @param message actionable error message
+ */
+ public ConfigurationException(String message) {
+ super(message);
+ }
+}
diff --git a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java
new file mode 100644
index 0000000..c774a00
--- /dev/null
+++ b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java
@@ -0,0 +1,316 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package software.amazon.elasticache.auth;
+
+import java.net.URI;
+import java.time.Clock;
+import java.time.Duration;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.function.Supplier;
+import java.util.regex.Pattern;
+
+import software.amazon.awssdk.auth.credentials.AwsCredentials;
+import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
+import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
+import software.amazon.awssdk.core.exception.SdkClientException;
+import software.amazon.awssdk.http.SdkHttpFullRequest;
+import software.amazon.awssdk.http.SdkHttpMethod;
+import software.amazon.awssdk.http.auth.aws.signer.AwsV4HttpSigner;
+import software.amazon.awssdk.http.auth.spi.signer.HttpSigner;
+import software.amazon.awssdk.http.auth.spi.signer.SignedRequest;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.regions.providers.AwsRegionProvider;
+import software.amazon.awssdk.regions.providers.DefaultAwsRegionProviderChain;
+
+/**
+ * Generates Amazon ElastiCache IAM authentication tokens.
+ *
+ * The provider is client-agnostic. Call {@link #getToken()} whenever a Redis or
+ * Valkey client opens a connection, and use {@link #getUserId()} as the ACL username.
+ * A new token is signed on every call so rotating AWS credentials are picked up.
+ *
+ *
Exactly one of {@link Builder#serverlessCacheName(String)} and
+ * {@link Builder#replicationGroupId(String)} is required.
+ */
+public final class ElastiCacheIamAuthTokenProvider {
+ private static final String URL_SCHEME_PREFIX = "https://";
+ private static final String SERVICE_NAME = "elasticache";
+ private static final String AWS_REGION_ENV_VAR = "AWS_REGION";
+ private static final Duration TOKEN_TTL = Duration.ofSeconds(900);
+ private static final Pattern CACHE_NAME_PATTERN =
+ Pattern.compile("^[a-zA-Z][a-zA-Z0-9]*(-[a-zA-Z0-9]+)*$");
+ private static final Pattern USER_ID_PATTERN =
+ Pattern.compile("^(?:default\\.)?[a-zA-Z][a-zA-Z0-9\\-]*$");
+ private static final String NO_REGION_MESSAGE =
+ "No AWS region found. Pass region explicitly, or configure one via "
+ + "AWS_REGION or the standard AWS SDK region provider chain.";
+ private static final String NO_CREDENTIALS_MESSAGE =
+ "No AWS credentials found. Configure credentials via the environment, "
+ + "shared config/credentials files, or an instance/container role.";
+
+ private final String cacheName;
+ private final String userId;
+ private final Region region;
+ private final boolean serverless;
+ private final AwsCredentialsProvider credentialsProvider;
+ private final AwsV4HttpSigner signer;
+ private final Clock signingClock;
+
+ private ElastiCacheIamAuthTokenProvider(Builder builder) {
+ Target target = resolveTarget(builder.serverlessCacheName, builder.replicationGroupId);
+ this.cacheName = target.cacheName;
+ this.serverless = target.serverless;
+ this.userId = validateAndNormalize("userId", builder.userId, USER_ID_PATTERN);
+ this.region = resolveRegion(
+ builder.region, builder.awsRegionEnvironmentProvider, builder.regionProvider);
+ this.credentialsProvider = builder.credentialsProvider == null
+ ? DefaultCredentialsProvider.builder().build()
+ : builder.credentialsProvider;
+ this.signer = AwsV4HttpSigner.create();
+ this.signingClock = builder.signingClock == null ? Clock.systemUTC() : builder.signingClock;
+ }
+
+ /**
+ * Creates a builder for an IAM authentication token provider.
+ *
+ * @return a new builder
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Returns the normalized ElastiCache user ID to send as the ACL username.
+ *
+ * @return lowercase ElastiCache user ID
+ */
+ public String getUserId() {
+ return userId;
+ }
+
+ /**
+ * Signs and returns a fresh IAM authentication token.
+ *
+ * @return presigned URL with the URI scheme removed
+ * @throws ConfigurationException if credentials cannot be resolved
+ */
+ public String getToken() {
+ AwsCredentials credentials;
+ try {
+ credentials = credentialsProvider.resolveCredentials();
+ } catch (RuntimeException exception) {
+ throw new ConfigurationException(NO_CREDENTIALS_MESSAGE);
+ }
+ if (credentials == null
+ || !hasText(credentials.accessKeyId())
+ || !hasText(credentials.secretAccessKey())) {
+ throw new ConfigurationException(NO_CREDENTIALS_MESSAGE);
+ }
+
+ SdkHttpFullRequest.Builder request = SdkHttpFullRequest.builder()
+ .method(SdkHttpMethod.GET)
+ .uri(URI.create(URL_SCHEME_PREFIX + cacheName + "/"))
+ .appendRawQueryParameter("Action", "connect")
+ .appendRawQueryParameter("User", userId);
+ if (serverless) {
+ request.appendRawQueryParameter("ResourceType", "ServerlessCache");
+ }
+
+ SignedRequest signedRequest = signer.sign(signRequest -> signRequest
+ .identity(credentials)
+ .request(request.build())
+ .putProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, SERVICE_NAME)
+ .putProperty(AwsV4HttpSigner.REGION_NAME, region.id())
+ .putProperty(
+ AwsV4HttpSigner.AUTH_LOCATION,
+ AwsV4HttpSigner.AuthLocation.QUERY_STRING)
+ .putProperty(AwsV4HttpSigner.EXPIRATION_DURATION, TOKEN_TTL)
+ .putProperty(HttpSigner.SIGNING_CLOCK, signingClock)
+ .build());
+
+ URI signedUri = ((SdkHttpFullRequest) signedRequest.request()).getUri();
+ String token = signedUri.toString();
+ if (!token.startsWith(URL_SCHEME_PREFIX)) {
+ throw new IllegalStateException(
+ "ElastiCache IAM signer returned an unexpected URI scheme");
+ }
+ return token.substring(URL_SCHEME_PREFIX.length());
+ }
+
+ private static Target resolveTarget(String serverlessCacheName, String replicationGroupId) {
+ boolean hasServerless = hasText(serverlessCacheName);
+ boolean hasReplicationGroup = hasText(replicationGroupId);
+ if (hasServerless == hasReplicationGroup) {
+ throw new InvalidParameterException(
+ "Invalid parameter combination for 'serverlessCacheName' and "
+ + "'replicationGroupId': exactly one must be provided.");
+ }
+ if (hasServerless) {
+ return new Target(
+ validateAndNormalize(
+ "serverlessCacheName", serverlessCacheName, CACHE_NAME_PATTERN),
+ true);
+ }
+ return new Target(
+ validateAndNormalize(
+ "replicationGroupId", replicationGroupId, CACHE_NAME_PATTERN),
+ false);
+ }
+
+ private static Region resolveRegion(
+ Region explicitRegion,
+ Supplier awsRegionEnvironmentProvider,
+ AwsRegionProvider regionProvider) {
+ if (explicitRegion != null) {
+ return explicitRegion;
+ }
+
+ Supplier environmentProvider = awsRegionEnvironmentProvider == null
+ ? () -> System.getenv(AWS_REGION_ENV_VAR)
+ : awsRegionEnvironmentProvider;
+ String environmentRegion = environmentProvider.get();
+ if (hasText(environmentRegion)) {
+ return Region.of(environmentRegion);
+ }
+
+ AwsRegionProvider provider = regionProvider == null
+ ? DefaultAwsRegionProviderChain.builder().build()
+ : regionProvider;
+ try {
+ Region resolved = provider.getRegion();
+ if (resolved != null && hasText(resolved.id())) {
+ return resolved;
+ }
+ } catch (SdkClientException exception) {
+ throw new ConfigurationException(NO_REGION_MESSAGE);
+ }
+ throw new ConfigurationException(NO_REGION_MESSAGE);
+ }
+
+ private static String validateAndNormalize(
+ String parameter, String value, Pattern pattern) {
+ String normalized = value == null ? "" : value.toLowerCase(Locale.ROOT);
+ if (!pattern.matcher(normalized).matches()) {
+ throw new InvalidParameterException(
+ "Invalid value for parameter '" + parameter + "': must match "
+ + pattern.pattern());
+ }
+ return normalized;
+ }
+
+ private static boolean hasText(String value) {
+ return value != null && !value.isEmpty();
+ }
+
+ private static final class Target {
+ private final String cacheName;
+ private final boolean serverless;
+
+ private Target(String cacheName, boolean serverless) {
+ this.cacheName = cacheName;
+ this.serverless = serverless;
+ }
+ }
+
+ /**
+ * Builder for {@link ElastiCacheIamAuthTokenProvider}.
+ */
+ public static final class Builder {
+ private String userId;
+ private Region region;
+ private String serverlessCacheName;
+ private String replicationGroupId;
+ private AwsCredentialsProvider credentialsProvider;
+ private Supplier awsRegionEnvironmentProvider;
+ private AwsRegionProvider regionProvider;
+ private Clock signingClock;
+
+ private Builder() {}
+
+ /**
+ * Sets the IAM-enabled ElastiCache user ID.
+ *
+ * @param userId ElastiCache user ID
+ * @return this builder
+ */
+ public Builder userId(String userId) {
+ this.userId = userId;
+ return this;
+ }
+
+ /**
+ * Sets the signing region. When omitted, {@code AWS_REGION} is checked before the
+ * standard AWS SDK region provider chain. The AWS SDK for Java does not read
+ * {@code AWS_DEFAULT_REGION}.
+ *
+ * @param region AWS region
+ * @return this builder
+ */
+ public Builder region(Region region) {
+ this.region = region;
+ return this;
+ }
+
+ /**
+ * Sets the serverless cache name.
+ *
+ * @param serverlessCacheName serverless cache name
+ * @return this builder
+ */
+ public Builder serverlessCacheName(String serverlessCacheName) {
+ this.serverlessCacheName = serverlessCacheName;
+ return this;
+ }
+
+ /**
+ * Sets the node-based replication group ID.
+ *
+ * @param replicationGroupId replication group ID
+ * @return this builder
+ */
+ public Builder replicationGroupId(String replicationGroupId) {
+ this.replicationGroupId = replicationGroupId;
+ return this;
+ }
+
+ /**
+ * Sets a credentials provider. The standard AWS credentials provider chain is the default.
+ *
+ * @param credentialsProvider AWS credentials provider
+ * @return this builder
+ */
+ public Builder credentialsProvider(AwsCredentialsProvider credentialsProvider) {
+ this.credentialsProvider = Objects.requireNonNull(
+ credentialsProvider, "credentialsProvider");
+ return this;
+ }
+
+ Builder regionProvider(AwsRegionProvider regionProvider) {
+ this.regionProvider = Objects.requireNonNull(regionProvider, "regionProvider");
+ return this;
+ }
+
+ Builder awsRegionEnvironmentProvider(Supplier awsRegionEnvironmentProvider) {
+ this.awsRegionEnvironmentProvider = Objects.requireNonNull(
+ awsRegionEnvironmentProvider, "awsRegionEnvironmentProvider");
+ return this;
+ }
+
+ Builder signingClock(Clock signingClock) {
+ this.signingClock = Objects.requireNonNull(signingClock, "signingClock");
+ return this;
+ }
+
+ /**
+ * Validates the configuration and creates the provider.
+ *
+ * @return configured IAM authentication token provider
+ */
+ public ElastiCacheIamAuthTokenProvider build() {
+ return new ElastiCacheIamAuthTokenProvider(this);
+ }
+ }
+}
diff --git a/java/src/main/java/software/amazon/elasticache/auth/InvalidParameterException.java b/java/src/main/java/software/amazon/elasticache/auth/InvalidParameterException.java
new file mode 100644
index 0000000..d7069fd
--- /dev/null
+++ b/java/src/main/java/software/amazon/elasticache/auth/InvalidParameterException.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package software.amazon.elasticache.auth;
+
+/**
+ * Indicates an invalid parameter value or parameter combination.
+ */
+public final class InvalidParameterException extends ToolkitInputException {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Creates an invalid parameter exception.
+ *
+ * @param message actionable error message
+ */
+ public InvalidParameterException(String message) {
+ super(message);
+ }
+}
diff --git a/java/src/main/java/software/amazon/elasticache/auth/ToolkitInputException.java b/java/src/main/java/software/amazon/elasticache/auth/ToolkitInputException.java
new file mode 100644
index 0000000..a34d821
--- /dev/null
+++ b/java/src/main/java/software/amazon/elasticache/auth/ToolkitInputException.java
@@ -0,0 +1,21 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package software.amazon.elasticache.auth;
+
+/**
+ * Base class for IAM token generation failures that callers can correct.
+ */
+public class ToolkitInputException extends RuntimeException {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Creates a correctable toolkit exception.
+ *
+ * @param message actionable error message
+ */
+ public ToolkitInputException(String message) {
+ super(message);
+ }
+}
diff --git a/java/src/main/java/software/amazon/elasticache/auth/package-info.java b/java/src/main/java/software/amazon/elasticache/auth/package-info.java
new file mode 100644
index 0000000..25886ab
--- /dev/null
+++ b/java/src/main/java/software/amazon/elasticache/auth/package-info.java
@@ -0,0 +1,9 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/**
+ * IAM authentication token generation for Amazon ElastiCache.
+ */
+package software.amazon.elasticache.auth;
diff --git a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java
new file mode 100644
index 0000000..bf18229
--- /dev/null
+++ b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java
@@ -0,0 +1,490 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package software.amazon.elasticache.auth;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+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.auth.credentials.AwsSessionCredentials;
+import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
+import software.amazon.awssdk.core.exception.SdkClientException;
+import software.amazon.awssdk.regions.Region;
+
+class ElastiCacheIamAuthTokenProviderTest {
+ private static final String CACHE = "my-cache";
+ private static final String USER = "testuser";
+ private static final Region REGION = Region.US_EAST_1;
+ private static final AwsCredentials CREDENTIALS = AwsSessionCredentials.create(
+ "FRAIDA1FODNN7EXAMPLE", "secret", "token");
+ private static final Clock KNOWN_ANSWER_CLOCK =
+ Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneOffset.UTC);
+ private static final AwsCredentials KNOWN_ANSWER_CREDENTIALS = AwsBasicCredentials.create(
+ "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY");
+
+ @Test
+ void generatesServerlessToken() {
+ String token = providerBuilder().serverlessCacheName(CACHE).build().getToken();
+
+ assertValidToken(token);
+ assertTrue(token.contains("ResourceType=ServerlessCache"));
+ }
+
+ @Test
+ void generatesReplicationGroupToken() {
+ String token = providerBuilder().replicationGroupId(CACHE).build().getToken();
+
+ assertValidToken(token);
+ assertFalse(token.contains("ResourceType"));
+ }
+
+ @Test
+ void exposesNormalizedUserIdAndSignsTheSameValue() {
+ ElastiCacheIamAuthTokenProvider provider = providerBuilder()
+ .serverlessCacheName("My-Cache")
+ .userId("MixedCaseUser")
+ .build();
+
+ assertEquals("mixedcaseuser", provider.getUserId());
+ String token = provider.getToken();
+ assertTrue(token.startsWith("my-cache/"));
+ assertTrue(token.contains("User=mixedcaseuser"));
+ assertFalse(token.contains("MixedCaseUser"));
+ }
+
+ @Test
+ void resolvesRegionAtConstruction() {
+ ElastiCacheIamAuthTokenProvider provider = providerBuilder()
+ .serverlessCacheName(CACHE)
+ .region(null)
+ .awsRegionEnvironmentProvider(() -> null)
+ .regionProvider(() -> Region.EU_WEST_1)
+ .build();
+
+ assertTrue(provider.getToken().contains("%2Feu-west-1%2F"));
+ }
+
+ @Test
+ void explicitRegionWinsOverRegionProvider() {
+ ElastiCacheIamAuthTokenProvider provider = providerBuilder()
+ .serverlessCacheName(CACHE)
+ .awsRegionEnvironmentProvider(() -> "ap-southeast-2")
+ .regionProvider(() -> Region.EU_WEST_1)
+ .build();
+
+ String token = provider.getToken();
+ assertTrue(token.contains("%2Fus-east-1%2F"));
+ assertFalse(token.contains("eu-west-1"));
+ }
+
+ @Test
+ void awsRegionEnvironmentWinsOverSdkRegionChain() {
+ ElastiCacheIamAuthTokenProvider provider = providerBuilder()
+ .serverlessCacheName(CACHE)
+ .region(null)
+ .awsRegionEnvironmentProvider(() -> "ap-southeast-2")
+ .regionProvider(() -> Region.EU_WEST_1)
+ .build();
+
+ String token = provider.getToken();
+ assertTrue(token.contains("%2Fap-southeast-2%2F"));
+ assertFalse(token.contains("eu-west-1"));
+ }
+
+ @Test
+ void sdkRegionChainIsUsedWhenAwsRegionIsAbsent() {
+ ElastiCacheIamAuthTokenProvider provider = providerBuilder()
+ .serverlessCacheName(CACHE)
+ .region(null)
+ .awsRegionEnvironmentProvider(() -> null)
+ .regionProvider(() -> Region.EU_WEST_1)
+ .build();
+
+ String token = provider.getToken();
+ assertTrue(token.contains("%2Feu-west-1%2F"));
+ }
+
+ @Test
+ void missingRegionFailsAtConstruction() {
+ String providerDetail = "secret region detail\nmust not escape";
+ ConfigurationException exception = assertThrows(
+ ConfigurationException.class,
+ () -> providerBuilder()
+ .serverlessCacheName(CACHE)
+ .region(null)
+ .awsRegionEnvironmentProvider(() -> null)
+ .regionProvider(() -> {
+ throw SdkClientException.create(providerDetail);
+ })
+ .build());
+
+ assertTrue(exception.getMessage().contains("AWS_REGION"));
+ assertTrue(exception.getMessage().contains("AWS SDK"));
+ assertFalse(renderStackTrace(exception).contains(providerDetail));
+ }
+
+ @Test
+ void nullRegionResultFailsAtConstruction() {
+ ConfigurationException exception = assertThrows(
+ ConfigurationException.class,
+ () -> providerBuilder()
+ .serverlessCacheName(CACHE)
+ .region(null)
+ .awsRegionEnvironmentProvider(() -> null)
+ .regionProvider(() -> null)
+ .build());
+
+ assertTrue(exception.getMessage().contains("AWS_REGION"));
+ assertTrue(exception.getMessage().contains("AWS SDK"));
+ }
+
+ @Test
+ void validatesParametersBeforeResolvingRegion() {
+ AtomicInteger regionCalls = new AtomicInteger();
+
+ assertThrows(
+ InvalidParameterException.class,
+ () -> providerBuilder()
+ .serverlessCacheName("invalid_name")
+ .region(null)
+ .awsRegionEnvironmentProvider(() -> null)
+ .regionProvider(() -> {
+ regionCalls.incrementAndGet();
+ return REGION;
+ })
+ .build());
+
+ assertEquals(0, regionCalls.get());
+ }
+
+ @Test
+ void readsCredentialsForEveryToken() {
+ AtomicInteger calls = new AtomicInteger();
+ AwsCredentialsProvider credentialsProvider = () -> {
+ calls.incrementAndGet();
+ return CREDENTIALS;
+ };
+ ElastiCacheIamAuthTokenProvider provider = providerBuilder()
+ .serverlessCacheName(CACHE)
+ .credentialsProvider(credentialsProvider)
+ .build();
+
+ provider.getToken();
+ provider.getToken();
+
+ assertEquals(2, calls.get());
+ }
+
+ @Test
+ void rotatedCredentialsChangeTheToken() {
+ AtomicInteger calls = new AtomicInteger();
+ AwsCredentialsProvider credentialsProvider = () -> calls.getAndIncrement() == 0
+ ? AwsBasicCredentials.create("AKIAIOSFODNN7EXAMPLE", "first-secret")
+ : AwsBasicCredentials.create("AKIAI44QH8DHBEXAMPLE", "second-secret");
+ ElastiCacheIamAuthTokenProvider provider = providerBuilder()
+ .serverlessCacheName(CACHE)
+ .credentialsProvider(credentialsProvider)
+ .build();
+
+ assertNotEquals(provider.getToken(), provider.getToken());
+ }
+
+ @Test
+ void retainsConstructionRegionWhileCredentialsRotate() {
+ AtomicInteger regionCalls = new AtomicInteger();
+ AtomicInteger credentialCalls = new AtomicInteger();
+ AtomicReference regionSource = new AtomicReference<>(REGION);
+ AwsCredentialsProvider credentialsProvider = () ->
+ credentialCalls.getAndIncrement() == 0
+ ? AwsBasicCredentials.create(
+ "AKIAIOSFODNN7EXAMPLE", "first-secret")
+ : AwsBasicCredentials.create(
+ "AKIAI44QH8DHBEXAMPLE", "second-secret");
+
+ ElastiCacheIamAuthTokenProvider provider = providerBuilder()
+ .serverlessCacheName(CACHE)
+ .region(null)
+ .awsRegionEnvironmentProvider(() -> null)
+ .regionProvider(() -> {
+ regionCalls.incrementAndGet();
+ return regionSource.get();
+ })
+ .credentialsProvider(credentialsProvider)
+ .build();
+
+ regionSource.set(Region.EU_WEST_1);
+ String first = provider.getToken();
+ regionSource.set(Region.AP_SOUTHEAST_1);
+ String second = provider.getToken();
+
+ assertEquals(1, regionCalls.get());
+ assertEquals(2, credentialCalls.get());
+ assertTrue(first.contains("%2Fus-east-1%2F"));
+ assertTrue(second.contains("%2Fus-east-1%2F"));
+ assertFalse(first.contains("%2Feu-west-1%2F"));
+ assertFalse(second.contains("%2Fap-southeast-1%2F"));
+ assertNotEquals(first, second);
+ }
+
+ @Test
+ void missingCredentialsHasActionableError() {
+ String providerDetail = "secret credential detail\nmust not escape";
+ ElastiCacheIamAuthTokenProvider provider = providerBuilder()
+ .serverlessCacheName(CACHE)
+ .credentialsProvider(() -> {
+ throw new IllegalStateException(providerDetail);
+ })
+ .build();
+
+ ConfigurationException exception =
+ assertThrows(ConfigurationException.class, provider::getToken);
+ assertEquals(
+ "No AWS credentials found. Configure credentials via the environment, "
+ + "shared config/credentials files, or an instance/container role.",
+ exception.getMessage());
+ assertFalse(renderStackTrace(exception).contains(providerDetail));
+ }
+
+ @Test
+ void unusableCredentialsHaveActionableError() {
+ AwsCredentials unusableCredentials = new AwsCredentials() {
+ @Override
+ public String accessKeyId() {
+ return "";
+ }
+
+ @Override
+ public String secretAccessKey() {
+ return "not-a-usable-key";
+ }
+ };
+ ElastiCacheIamAuthTokenProvider provider = providerBuilder()
+ .serverlessCacheName(CACHE)
+ .credentialsProvider(() -> unusableCredentials)
+ .build();
+
+ assertThrows(ConfigurationException.class, provider::getToken);
+ }
+
+ @Test
+ void requiresExactlyOneTarget() {
+ assertThrows(InvalidParameterException.class, () -> providerBuilder().build());
+ assertThrows(
+ InvalidParameterException.class,
+ () -> providerBuilder()
+ .serverlessCacheName(CACHE)
+ .replicationGroupId(CACHE)
+ .build());
+ }
+
+ @Test
+ void rejectsInvalidCacheNamesWithoutEchoingThem() {
+ List badNames = Arrays.asList(
+ "evil.com/path",
+ "my-cache@evil.com",
+ "my-cache/?X=1",
+ "my cache",
+ "my_cache",
+ "1-cache",
+ "-cache",
+ "cache-",
+ "my--cache",
+ "-");
+
+ for (String badName : badNames) {
+ InvalidParameterException exception = assertThrows(
+ InvalidParameterException.class,
+ () -> providerBuilder().serverlessCacheName(badName).build());
+ assertTrue(exception.getMessage().contains("serverlessCacheName"));
+ }
+ }
+
+ @Test
+ void invalidReplicationGroupIdNamesThatParameter() {
+ InvalidParameterException exception = assertThrows(
+ InvalidParameterException.class,
+ () -> providerBuilder().replicationGroupId("bad_name!").build());
+
+ assertTrue(exception.getMessage().contains("replicationGroupId"));
+ }
+
+ @Test
+ void emptyCacheNameIsRejectedAsMissingTarget() {
+ assertThrows(
+ InvalidParameterException.class,
+ () -> providerBuilder().serverlessCacheName("").build());
+ }
+
+ @Test
+ void parameterErrorsDoNotEchoHostileValues() {
+ String hostile = "evil.com/inject\nlog-entry";
+ InvalidParameterException exception = assertThrows(
+ InvalidParameterException.class,
+ () -> providerBuilder().serverlessCacheName(hostile).build());
+
+ assertTrue(exception.getMessage().contains("serverlessCacheName"));
+ assertFalse(exception.getMessage().contains(hostile));
+ }
+
+ @Test
+ void acceptsValidCacheNames() {
+ for (String name : Arrays.asList("c", "my-cache", "cache1", "a-1-b-2", "MyCache")) {
+ String token = providerBuilder().serverlessCacheName(name).build().getToken();
+ assertTrue(token.startsWith(name.toLowerCase() + "/"));
+ }
+ }
+
+ @Test
+ void validatesUserIds() {
+ for (String userId : Arrays.asList(
+ "", "1user", "-user", "user name", "user@host", "user.name",
+ "default.", "default.1x", "notdefault.foo")) {
+ InvalidParameterException exception = assertThrows(
+ InvalidParameterException.class,
+ () -> providerBuilder()
+ .serverlessCacheName(CACHE)
+ .userId(userId)
+ .build());
+ assertTrue(exception.getMessage().contains("userId"));
+ }
+
+ for (String userId : Arrays.asList(
+ "iam-user", "myuser", "default", "default.iam-user",
+ "default.other", "DEFAULT.IAM-USER", "a-1-b-2")) {
+ ElastiCacheIamAuthTokenProvider provider = providerBuilder()
+ .serverlessCacheName(CACHE)
+ .userId(userId)
+ .build();
+ assertEquals(userId.toLowerCase(), provider.getUserId());
+ }
+ }
+
+ @Test
+ void signsServiceManagedDefaultUser() {
+ ElastiCacheIamAuthTokenProvider provider = providerBuilder()
+ .serverlessCacheName(CACHE)
+ .userId("DEFAULT.IAM-USER")
+ .build();
+
+ assertEquals("default.iam-user", provider.getUserId());
+ assertTrue(provider.getToken().contains("User=default.iam-user"));
+ }
+
+ @Test
+ void configurationExceptionIsNotAParameterException() {
+ assertFalse(InvalidParameterException.class.isAssignableFrom(ConfigurationException.class));
+ }
+
+ @Test
+ void serverlessTokenMatchesKnownAnswer() {
+ String token = knownAnswerProviderBuilder()
+ .serverlessCacheName(CACHE)
+ .build()
+ .getToken();
+
+ assertKnownAnswerToken(
+ token,
+ true,
+ "28f349fd92f74e0192de149a74743ae7d087bb136523c342bdd67632a8360023");
+ }
+
+ @Test
+ void replicationGroupTokenMatchesKnownAnswer() {
+ String token = knownAnswerProviderBuilder()
+ .replicationGroupId(CACHE)
+ .build()
+ .getToken();
+
+ assertKnownAnswerToken(
+ token,
+ false,
+ "cd78a7de74c1bb1c8c685cac428f783b5433ce5fbf569af867570311d6a85797");
+ }
+
+ private static ElastiCacheIamAuthTokenProvider.Builder providerBuilder() {
+ return ElastiCacheIamAuthTokenProvider.builder()
+ .userId(USER)
+ .region(REGION)
+ .credentialsProvider(StaticCredentialsProvider.create(CREDENTIALS));
+ }
+
+ private static ElastiCacheIamAuthTokenProvider.Builder knownAnswerProviderBuilder() {
+ return ElastiCacheIamAuthTokenProvider.builder()
+ .userId(USER)
+ .region(REGION)
+ .credentialsProvider(StaticCredentialsProvider.create(KNOWN_ANSWER_CREDENTIALS))
+ .signingClock(KNOWN_ANSWER_CLOCK);
+ }
+
+ private static void assertValidToken(String token) {
+ assertTrue(token.startsWith(CACHE + "/"));
+ assertTrue(token.contains("Action=connect"));
+ assertTrue(token.contains("User=" + USER));
+ assertTrue(token.contains("X-Amz-Algorithm=AWS4-HMAC-SHA256"));
+ assertTrue(token.contains("X-Amz-Credential"));
+ assertTrue(token.contains("X-Amz-Security-Token=token"));
+ assertTrue(token.contains("X-Amz-Signature"));
+ assertTrue(token.contains("X-Amz-Expires=900"));
+ assertFalse(token.startsWith("https://"));
+ }
+
+ private static String renderStackTrace(Throwable throwable) {
+ StringWriter output = new StringWriter();
+ throwable.printStackTrace(new PrintWriter(output));
+ return output.toString();
+ }
+
+ private static void assertKnownAnswerToken(
+ String token, boolean serverless, String expectedSignature) {
+ assertTrue(token.startsWith(CACHE + "/?"));
+ Map parameters = rawQueryParameters(token);
+ assertEquals("connect", parameters.get("Action"));
+ assertEquals(USER, parameters.get("User"));
+ assertEquals("AWS4-HMAC-SHA256", parameters.get("X-Amz-Algorithm"));
+ assertEquals(
+ "AKIAIOSFODNN7EXAMPLE%2F20250101%2Fus-east-1"
+ + "%2Felasticache%2Faws4_request",
+ parameters.get("X-Amz-Credential"));
+ assertEquals("20250101T000000Z", parameters.get("X-Amz-Date"));
+ assertEquals("900", parameters.get("X-Amz-Expires"));
+ assertEquals("host", parameters.get("X-Amz-SignedHeaders"));
+ assertEquals(expectedSignature, parameters.get("X-Amz-Signature"));
+ if (serverless) {
+ assertEquals("ServerlessCache", parameters.get("ResourceType"));
+ assertEquals(9, parameters.size());
+ } else {
+ assertFalse(parameters.containsKey("ResourceType"));
+ assertEquals(8, parameters.size());
+ }
+ }
+
+ private static Map rawQueryParameters(String token) {
+ Map parameters = new LinkedHashMap<>();
+ String query = token.substring(token.indexOf('?') + 1);
+ for (String pair : query.split("&")) {
+ String[] parts = pair.split("=", 2);
+ parameters.put(parts[0], parts.length == 2 ? parts[1] : "");
+ }
+ return parameters;
+ }
+}
diff --git a/python/README.md b/python/README.md
index 31211f1..5f5c67d 100644
--- a/python/README.md
+++ b/python/README.md
@@ -69,6 +69,10 @@ The chain is checked in this order:
3. Container credentials (Amazon ECS / EKS).
4. EC2 instance profile credentials (IMDS).
+Missing credentials are reported when a token is requested. Expired or otherwise
+invalid credentials cannot be detected during local signing: token generation can
+succeed, but ElastiCache will reject the token when the client connects.
+
### Reconnecting clients
Clients such as redis-py and valkey-py request credentials on every reconnection.
diff --git a/scripts/java_dev.py b/scripts/java_dev.py
new file mode 100644
index 0000000..1162329
--- /dev/null
+++ b/scripts/java_dev.py
@@ -0,0 +1,45 @@
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+import argparse
+import json
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+POM = ROOT / "java" / "pom.xml"
+MAVEN_NAMESPACE = {"m": "http://maven.apache.org/POM/4.0.0"}
+
+
+def list_supported_versions() -> None:
+ root = ET.parse(POM).getroot()
+ value = root.findtext(
+ "m:properties/m:java.supported.versions", namespaces=MAVEN_NAMESPACE
+ )
+ if value is None:
+ raise SystemExit("java.supported.versions is missing from java/pom.xml")
+
+ versions = json.loads(value)
+ if (
+ not isinstance(versions, list)
+ or not versions
+ or not all(isinstance(version, str) and version for version in versions)
+ ):
+ raise SystemExit(
+ "java.supported.versions must be a non-empty JSON string array"
+ )
+ result = {"all": versions, "min": versions[0], "max": versions[-1]}
+ print(json.dumps(result, separators=(",", ":")))
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("command", choices=["list-supported-versions"])
+ args = parser.parse_args()
+
+ if args.command == "list-supported-versions":
+ list_supported_versions()
+
+
+if __name__ == "__main__":
+ main()