"
+```
+
+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..db00e70
--- /dev/null
+++ b/java/examples/pom.xml
@@ -0,0 +1,51 @@
+
+
+
+ 4.0.0
+
+ software.amazon.elasticache
+ developer-toolkit-elasticache-examples
+ 1.0.0
+
+
+ UTF-8
+ 8
+ 7.7.0.RELEASE
+
+
+
+
+ software.amazon.elasticache
+ developer-toolkit-elasticache
+ 1.0.0
+
+
+ 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..b2f26a4
--- /dev/null
+++ b/java/examples/src/main/java/software/amazon/elasticache/examples/ConnectWithLettuce.java
@@ -0,0 +1,78 @@
+/*
+ * 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.ElastiCacheIamAuthTokenManager;
+
+/**
+ * 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;
+
+ ElastiCacheIamAuthTokenManager auth = ElastiCacheIamAuthTokenManager.builder()
+ .serverlessCacheName(cacheName)
+ .userId(userId)
+ .region(region)
+ .build();
+
+ RedisCredentialsProvider credentialsProvider = new RedisCredentialsProvider() {
+ @Override
+ public Mono resolveCredentials() {
+ return Mono.fromSupplier(() -> {
+ ElastiCacheIamAuthTokenManager.Credentials credentials =
+ auth.getCredentials();
+ return RedisCredentials.just(
+ credentials.getUserId(), credentials.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();
+ auth.close();
+ }
+ }
+}
diff --git a/java/integration-tests/pom.xml b/java/integration-tests/pom.xml
new file mode 100644
index 0000000..ded6372
--- /dev/null
+++ b/java/integration-tests/pom.xml
@@ -0,0 +1,61 @@
+
+
+
+ 4.0.0
+
+ software.amazon.elasticache
+ developer-toolkit-elasticache-integration-tests
+ 1.0.0
+
+
+ UTF-8
+ 8
+ 5.13.4
+
+
+
+
+ software.amazon.elasticache
+ developer-toolkit-elasticache
+ 1.0.0
+
+
+ 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..bb87fcf
--- /dev/null
+++ b/java/integration-tests/src/test/java/software/amazon/elasticache/integration/PublicApiTest.java
@@ -0,0 +1,43 @@
+/*
+ * 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.awssdk.auth.credentials.AwsBasicCredentials;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.elasticache.auth.ConfigurationException;
+import software.amazon.elasticache.auth.ElastiCacheIamAuthTokenManager;
+import software.amazon.elasticache.auth.ElastiCacheIamAuthTokenProvider;
+import software.amazon.elasticache.auth.InvalidParameterException;
+import software.amazon.elasticache.auth.TokenRefreshException;
+import software.amazon.elasticache.auth.ToolkitInputException;
+
+class PublicApiTest {
+ @Test
+ void installedArtifactExposesPublicApi() {
+ assertNotNull(ElastiCacheIamAuthTokenProvider.builder());
+ assertNotNull(ElastiCacheIamAuthTokenManager.builder());
+ try (ElastiCacheIamAuthTokenManager manager =
+ ElastiCacheIamAuthTokenManager.builder()
+ .serverlessCacheName("test-cache")
+ .userId("test-user")
+ .region(Region.US_EAST_1)
+ .credentialsProvider(() ->
+ AwsBasicCredentials.create("test-access-key", "test-secret-key"))
+ .build()) {
+ String token = manager.refreshToken();
+ assertNotNull(token);
+ }
+ assertTrue(ToolkitInputException.class.isAssignableFrom(ConfigurationException.class));
+ assertTrue(ToolkitInputException.class.isAssignableFrom(InvalidParameterException.class));
+ assertFalse(ToolkitInputException.class.isAssignableFrom(TokenRefreshException.class));
+ assertFalse(
+ InvalidParameterException.class.isAssignableFrom(ConfigurationException.class));
+ }
+}
diff --git a/java/pom.xml b/java/pom.xml
new file mode 100644
index 0000000..40b626b
--- /dev/null
+++ b/java/pom.xml
@@ -0,0 +1,241 @@
+
+
+
+ 4.0.0
+
+ software.amazon.elasticache
+ developer-toolkit-elasticache
+ 1.0.0
+ 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
+
+
+
+
+
+ amazonwebservices
+ Amazon Web Services
+ https://aws.amazon.com
+
+ developer
+
+
+
+
+
+ scm:git:https://github.com/aws/developer-toolkit-elasticache.git
+ https://github.com/aws/developer-toolkit-elasticache/tree/main/java
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+ release
+
+
+
+ org.apache.maven.plugins
+ maven-gpg-plugin
+ 3.2.8
+
+
+ sign-artifacts
+ verify
+
+ sign
+
+
+ true
+
+
+
+
+
+ org.sonatype.central
+ central-publishing-maven-plugin
+ 0.11.0
+ true
+
+ central
+ true
+ published
+
+ 3600
+ ${project.artifactId} ${project.version}
+
+
+
+
+
+
+
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/ElastiCacheIamAuthTokenManager.java b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java
new file mode 100644
index 0000000..2f70e9d
--- /dev/null
+++ b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java
@@ -0,0 +1,685 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package software.amazon.elasticache.auth;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.RejectedExecutionException;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+import java.util.function.DoubleSupplier;
+import java.util.function.LongSupplier;
+import java.util.function.Supplier;
+
+import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider;
+import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider;
+import software.amazon.awssdk.regions.Region;
+import software.amazon.awssdk.regions.providers.AwsRegionProvider;
+import software.amazon.awssdk.regions.providers.DefaultAwsRegionProviderChain;
+
+/**
+ * Caches an ElastiCache IAM authentication token and refreshes it before expiry.
+ *
+ * The first token is generated lazily. Calls reuse the cached token while it
+ * remains valid, and concurrent callers share a single refresh. Refresh failures
+ * are retried in the background while a valid cached token continues to be served.
+ *
+ *
Call {@link #close()} when the manager is no longer needed.
+ */
+public final class ElastiCacheIamAuthTokenManager implements AutoCloseable {
+ private static final Duration DEFAULT_REFRESH_AFTER = Duration.ofMinutes(5);
+ private static final int MAX_ATTEMPTS = 8;
+ private static final long BASE_DELAY_MILLIS = 100;
+ private static final long MAX_DELAY_MILLIS = 5_000;
+ private static final double JITTER_RATIO = 0.2;
+ private static final Duration TOKEN_SERVE_MARGIN = Duration.ofSeconds(20);
+ private static final Duration EFFECTIVE_TOKEN_LIFETIME =
+ ElastiCacheIamAuthTokenProvider.TOKEN_TTL.minus(TOKEN_SERVE_MARGIN);
+ private static final String CLOSED_MESSAGE = "The token manager is closed.";
+ private static final String REFRESH_CANCELLED_MESSAGE =
+ "Token refresh was cancelled because the token manager was closed.";
+ private static final String REFRESH_FAILED_MESSAGE =
+ "Could not refresh the ElastiCache IAM authentication token before "
+ + "the previous token expired.";
+
+ private final Object lock = new Object();
+ private final ElastiCacheIamAuthTokenProvider provider;
+ private final long refreshAfterMillis;
+ private final Consumer onTokenChanged;
+ private final LongSupplier currentTimeMillis;
+ private final DoubleSupplier random;
+ private final Scheduler scheduler;
+ private final boolean ownsScheduler;
+ private final Runnable beforeInstall;
+ private final Runnable onSharedRefresh;
+
+ private CachedToken cached;
+ private CompletableFuture inFlight;
+ private Cancellable refreshTask;
+ private Cancellable retryTask;
+ private long retryNotBefore;
+ private boolean closed;
+
+ private ElastiCacheIamAuthTokenManager(Builder builder) {
+ this.provider = ElastiCacheIamAuthTokenProvider.builder()
+ .userId(builder.userId)
+ .region(builder.region)
+ .serverlessCacheName(builder.serverlessCacheName)
+ .replicationGroupId(builder.replicationGroupId)
+ .credentialsProvider(builder.credentialsProvider == null
+ ? DefaultCredentialsProvider.builder().build()
+ : builder.credentialsProvider)
+ .awsRegionEnvironmentProvider(builder.awsRegionEnvironmentProvider == null
+ ? () -> System.getenv("AWS_REGION")
+ : builder.awsRegionEnvironmentProvider)
+ .regionProvider(builder.regionProvider == null
+ ? DefaultAwsRegionProviderChain.builder().build()
+ : builder.regionProvider)
+ .signingClock(builder.signingClock == null
+ ? Clock.systemUTC()
+ : builder.signingClock)
+ .build();
+ this.refreshAfterMillis = resolveRefreshAfterMillis(builder.refreshAfter);
+ this.onTokenChanged = builder.onTokenChanged;
+ this.currentTimeMillis = builder.currentTimeMillis == null
+ ? System::currentTimeMillis
+ : builder.currentTimeMillis;
+ this.random = builder.random == null ? Math::random : builder.random;
+ this.beforeInstall = builder.beforeInstall;
+ this.onSharedRefresh = builder.onSharedRefresh;
+ if (builder.scheduler == null) {
+ this.scheduler = new DefaultScheduler();
+ this.ownsScheduler = true;
+ } else {
+ this.scheduler = builder.scheduler;
+ this.ownsScheduler = false;
+ }
+ }
+
+ /**
+ * Creates a builder for a caching IAM authentication token manager.
+ *
+ * @return a new builder
+ */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ /**
+ * Returns the normalized ElastiCache user ID.
+ *
+ * @return lowercase ElastiCache user ID
+ */
+ public String getUserId() {
+ return provider.getUserId();
+ }
+
+ /**
+ * Returns the normalized user ID and a valid token together.
+ *
+ * @return credentials suitable for a client credential-provider adapter
+ */
+ public Credentials getCredentials() {
+ return new Credentials(getUserId(), getToken());
+ }
+
+ /**
+ * Returns a valid token, generating one on the first call.
+ *
+ * While a cached token remains valid it is returned immediately. Once its
+ * refresh point is reached, refresh proceeds in the background.
+ *
+ * @return current IAM authentication token
+ * @throws TokenRefreshException if the manager is closed or an expired token
+ * could not be replaced
+ */
+ public String getToken() {
+ synchronized (lock) {
+ ensureOpen();
+ long now = currentTimeMillis.getAsLong();
+ if (cached != null && now < cached.expiresAt) {
+ String token = cached.token;
+ if (now >= cached.refreshAt && now >= retryNotBefore) {
+ scheduleRefreshLocked(0);
+ }
+ return token;
+ }
+ }
+ return await(refresh());
+ }
+
+ /**
+ * Forces a token refresh without waiting for the scheduled refresh.
+ *
+ *
The currently cached token is invalidated before new AWS credentials are
+ * resolved and a replacement token is signed. Concurrent token callers share
+ * the same refresh, including a background refresh already in progress or in
+ * retry backoff.
+ *
+ *
Use this operation after a client rejects the cached token. Discard the
+ * rejected connection before opening a replacement connection with the
+ * returned token.
+ *
+ * @return newly installed IAM authentication token
+ * @throws TokenRefreshException if the manager is closed
+ * @throws ConfigurationException if usable AWS credentials cannot be resolved
+ */
+ public String refreshToken() {
+ synchronized (lock) {
+ ensureOpen();
+ cached = null;
+ cancel(refreshTask);
+ refreshTask = null;
+ retryNotBefore = 0;
+ }
+ return await(refresh());
+ }
+
+ /**
+ * Stops all background work and releases the manager's scheduler.
+ *
+ *
Subsequent token requests throw {@link TokenRefreshException}.
+ */
+ @Override
+ public void close() {
+ CompletableFuture pending;
+ synchronized (lock) {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ cancel(refreshTask);
+ refreshTask = null;
+ cancel(retryTask);
+ retryTask = null;
+ pending = inFlight;
+ }
+ if (pending != null) {
+ pending.completeExceptionally(
+ new TokenRefreshException(REFRESH_CANCELLED_MESSAGE));
+ }
+ if (ownsScheduler) {
+ scheduler.close();
+ }
+ }
+
+ private CompletableFuture refresh() {
+ CompletableFuture refresh;
+ synchronized (lock) {
+ ensureOpen();
+ if (inFlight != null) {
+ if (onSharedRefresh != null) {
+ onSharedRefresh.run();
+ }
+ return inFlight;
+ }
+ refresh = new CompletableFuture<>();
+ inFlight = refresh;
+ refresh.whenComplete((result, error) -> {
+ synchronized (lock) {
+ if (inFlight == refresh) {
+ inFlight = null;
+ }
+ }
+ });
+ }
+ runRefreshAttempt(refresh, 1, null);
+ return refresh;
+ }
+
+ private void startBackgroundRefresh() {
+ try {
+ refresh();
+ } catch (TokenRefreshException ignored) {
+ // Closing the manager races safely with a scheduled refresh.
+ }
+ }
+
+ private void runRefreshAttempt(
+ CompletableFuture refresh, int attempt, Throwable lastError) {
+ synchronized (lock) {
+ if (closed) {
+ refresh.completeExceptionally(new TokenRefreshException(
+ REFRESH_CANCELLED_MESSAGE, lastError));
+ return;
+ }
+ retryTask = null;
+ }
+
+ try {
+ long issuedAt = currentTimeMillis.getAsLong();
+ String token = provider.getToken();
+ if (beforeInstall != null) {
+ beforeInstall.run();
+ }
+ installAndComplete(refresh, token, issuedAt);
+ } catch (Throwable failure) {
+ if (failure instanceof Error) {
+ refresh.completeExceptionally(failure);
+ throw (Error) failure;
+ }
+ RuntimeException exception = failure instanceof RuntimeException
+ ? (RuntimeException) failure
+ : new RuntimeException(failure);
+ synchronized (lock) {
+ if (closed) {
+ refresh.completeExceptionally(
+ new TokenRefreshException(
+ REFRESH_CANCELLED_MESSAGE, exception));
+ return;
+ }
+ }
+ if (attempt == MAX_ATTEMPTS) {
+ finishFailedRefresh(refresh, exception);
+ return;
+ }
+ scheduleRetry(refresh, attempt + 1, exception, backoffDelay(attempt));
+ }
+ }
+
+ private void scheduleRetry(
+ CompletableFuture refresh,
+ int nextAttempt,
+ Throwable lastError,
+ long delayMillis) {
+ try {
+ Cancellable task = scheduler.schedule(
+ () -> runRefreshAttempt(refresh, nextAttempt, lastError), delayMillis);
+ synchronized (lock) {
+ if (closed) {
+ task.cancel();
+ refresh.completeExceptionally(
+ new TokenRefreshException(
+ REFRESH_CANCELLED_MESSAGE, lastError));
+ } else {
+ retryTask = task;
+ }
+ }
+ } catch (RejectedExecutionException exception) {
+ refresh.completeExceptionally(
+ new TokenRefreshException(REFRESH_CANCELLED_MESSAGE, lastError));
+ }
+ }
+
+ private void finishFailedRefresh(
+ CompletableFuture refresh, RuntimeException lastError) {
+ synchronized (lock) {
+ long now = currentTimeMillis.getAsLong();
+ if (cached != null && now < cached.expiresAt) {
+ retryNotBefore = now + MAX_DELAY_MILLIS;
+ scheduleRefreshLocked(MAX_DELAY_MILLIS);
+ refresh.complete(cached.token);
+ return;
+ }
+ if (cached == null) {
+ refresh.completeExceptionally(lastError);
+ } else {
+ refresh.completeExceptionally(
+ new TokenRefreshException(REFRESH_FAILED_MESSAGE, lastError));
+ }
+ }
+ }
+
+ private void installAndComplete(
+ CompletableFuture refresh, String token, long issuedAt) {
+ synchronized (lock) {
+ if (closed) {
+ refresh.completeExceptionally(
+ new TokenRefreshException(REFRESH_CANCELLED_MESSAGE));
+ return;
+ }
+ cached = new CachedToken(
+ token,
+ issuedAt + refreshAfterMillis,
+ issuedAt + EFFECTIVE_TOKEN_LIFETIME.toMillis());
+ retryNotBefore = 0;
+ scheduleRefreshLocked(refreshAfterMillis);
+ refresh.complete(token);
+ }
+ notifyTokenChanged(token);
+ }
+
+ private void notifyTokenChanged(String token) {
+ if (onTokenChanged == null) {
+ return;
+ }
+ try {
+ scheduler.execute(() -> {
+ try {
+ onTokenChanged.accept(token);
+ } catch (Throwable ignored) {
+ // A consumer callback cannot invalidate a successfully signed token.
+ }
+ });
+ } catch (Throwable ignored) {
+ // Failure to dispatch a callback cannot invalidate a signed token.
+ }
+ }
+
+ private void scheduleRefreshLocked(long delayMillis) {
+ cancel(refreshTask);
+ refreshTask = null;
+ if (closed) {
+ return;
+ }
+ Cancellable[] scheduled = new Cancellable[1];
+ scheduled[0] = scheduler.schedule(() -> {
+ synchronized (lock) {
+ if (refreshTask == scheduled[0]) {
+ refreshTask = null;
+ }
+ }
+ startBackgroundRefresh();
+ }, delayMillis);
+ refreshTask = scheduled[0];
+ }
+
+ private long backoffDelay(int failedAttempt) {
+ long exponential = BASE_DELAY_MILLIS << Math.min(failedAttempt - 1, 30);
+ long delay = Math.min(exponential, MAX_DELAY_MILLIS);
+ double jitter = 1 - JITTER_RATIO + 2 * JITTER_RATIO * random.getAsDouble();
+ return Math.round(delay * jitter);
+ }
+
+ private void ensureOpen() {
+ if (closed) {
+ throw new TokenRefreshException(CLOSED_MESSAGE);
+ }
+ }
+
+ private static void cancel(Cancellable task) {
+ if (task != null) {
+ task.cancel();
+ }
+ }
+
+ private static String await(CompletableFuture future) {
+ try {
+ return future.join();
+ } catch (CompletionException exception) {
+ Throwable cause = exception.getCause();
+ if (cause instanceof RuntimeException) {
+ throw (RuntimeException) cause;
+ }
+ if (cause instanceof Error) {
+ throw (Error) cause;
+ }
+ throw exception;
+ }
+ }
+
+ private static long resolveRefreshAfterMillis(Duration refreshAfter) {
+ Duration resolved = refreshAfter == null ? DEFAULT_REFRESH_AFTER : refreshAfter;
+ long millis;
+ try {
+ millis = resolved.toMillis();
+ } catch (ArithmeticException exception) {
+ millis = -1;
+ }
+ if (resolved.isNegative()
+ || resolved.isZero()
+ || millis <= 0
+ || resolved.compareTo(EFFECTIVE_TOKEN_LIFETIME) >= 0) {
+ throw new InvalidParameterException(
+ "Invalid value for parameter 'refreshAfter': must be at least "
+ + "1 millisecond and less than 14 minutes 40 seconds.");
+ }
+ return millis;
+ }
+
+ /**
+ * Immutable username and token pair returned by {@link #getCredentials()}.
+ */
+ public static final class Credentials {
+ private final String userId;
+ private final String token;
+
+ private Credentials(String userId, String token) {
+ this.userId = userId;
+ this.token = token;
+ }
+
+ /**
+ * Returns the normalized ElastiCache user ID.
+ *
+ * @return lowercase user ID
+ */
+ public String getUserId() {
+ return userId;
+ }
+
+ /**
+ * Returns the cached IAM authentication token.
+ *
+ * @return IAM authentication token
+ */
+ public String getToken() {
+ return token;
+ }
+ }
+
+ /**
+ * Builder for {@link ElastiCacheIamAuthTokenManager}.
+ */
+ public static final class Builder {
+ private String userId;
+ private Region region;
+ private String serverlessCacheName;
+ private String replicationGroupId;
+ private AwsCredentialsProvider credentialsProvider;
+ private Duration refreshAfter;
+ private Consumer onTokenChanged;
+ private Supplier awsRegionEnvironmentProvider;
+ private AwsRegionProvider regionProvider;
+ private Clock signingClock;
+ private LongSupplier currentTimeMillis;
+ private DoubleSupplier random;
+ private Scheduler scheduler;
+ private Runnable beforeInstall;
+ private Runnable onSharedRefresh;
+
+ 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.
+ *
+ * @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 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;
+ }
+
+ /**
+ * Sets how long after issuance the token is refreshed in the background.
+ *
+ * The default is 5 minutes. The value must be at least 1 millisecond
+ * and less than the effective 14-minute-40-second token lifetime. The
+ * effective lifetime reserves 20 seconds for clock skew and transit time.
+ *
+ * @param refreshAfter refresh delay after token issuance
+ * @return this builder
+ */
+ public Builder refreshAfter(Duration refreshAfter) {
+ this.refreshAfter = Objects.requireNonNull(refreshAfter, "refreshAfter");
+ return this;
+ }
+
+ /**
+ * Sets a callback invoked on the manager's refresh thread after each newly
+ * generated token is installed and made available to token callers.
+ *
+ *
The callback should return quickly because it delays this manager's
+ * next background refresh. Callback failures are ignored and do not discard
+ * the new token.
+ *
+ * @param onTokenChanged token-change callback
+ * @return this builder
+ */
+ public Builder onTokenChanged(Consumer onTokenChanged) {
+ this.onTokenChanged = Objects.requireNonNull(onTokenChanged, "onTokenChanged");
+ return this;
+ }
+
+ Builder regionProvider(AwsRegionProvider regionProvider) {
+ this.regionProvider = Objects.requireNonNull(regionProvider, "regionProvider");
+ return this;
+ }
+
+ Builder awsRegionEnvironmentProvider(Supplier provider) {
+ this.awsRegionEnvironmentProvider = Objects.requireNonNull(provider, "provider");
+ return this;
+ }
+
+ Builder signingClock(Clock signingClock) {
+ this.signingClock = Objects.requireNonNull(signingClock, "signingClock");
+ return this;
+ }
+
+ Builder currentTimeMillis(LongSupplier currentTimeMillis) {
+ this.currentTimeMillis =
+ Objects.requireNonNull(currentTimeMillis, "currentTimeMillis");
+ return this;
+ }
+
+ Builder random(DoubleSupplier random) {
+ this.random = Objects.requireNonNull(random, "random");
+ return this;
+ }
+
+ Builder scheduler(Scheduler scheduler) {
+ this.scheduler = Objects.requireNonNull(scheduler, "scheduler");
+ return this;
+ }
+
+ Builder beforeInstall(Runnable beforeInstall) {
+ this.beforeInstall = Objects.requireNonNull(beforeInstall, "beforeInstall");
+ return this;
+ }
+
+ Builder onSharedRefresh(Runnable onSharedRefresh) {
+ this.onSharedRefresh =
+ Objects.requireNonNull(onSharedRefresh, "onSharedRefresh");
+ return this;
+ }
+
+ /**
+ * Validates the configuration and creates the manager.
+ *
+ * @return configured caching token manager
+ */
+ public ElastiCacheIamAuthTokenManager build() {
+ return new ElastiCacheIamAuthTokenManager(this);
+ }
+ }
+
+ interface Cancellable {
+ void cancel();
+ }
+
+ interface Scheduler {
+ Cancellable schedule(Runnable task, long delayMillis);
+
+ void execute(Runnable task);
+
+ void close();
+ }
+
+ private static final class DefaultScheduler implements Scheduler {
+ private final ScheduledExecutorService executor =
+ Executors.newSingleThreadScheduledExecutor(new DaemonThreadFactory());
+
+ @Override
+ public Cancellable schedule(Runnable task, long delayMillis) {
+ ScheduledFuture> future =
+ executor.schedule(task, delayMillis, TimeUnit.MILLISECONDS);
+ return () -> future.cancel(false);
+ }
+
+ @Override
+ public void execute(Runnable task) {
+ executor.execute(task);
+ }
+
+ @Override
+ public void close() {
+ executor.shutdownNow();
+ }
+ }
+
+ private static final class DaemonThreadFactory implements ThreadFactory {
+ @Override
+ public Thread newThread(Runnable runnable) {
+ Thread thread = new Thread(runnable, "elasticache-iam-token-refresh");
+ thread.setDaemon(true);
+ return thread;
+ }
+ }
+
+ private static final class CachedToken {
+ private final String token;
+ private final long refreshAt;
+ private final long expiresAt;
+
+ private CachedToken(String token, long refreshAt, long expiresAt) {
+ this.token = token;
+ this.refreshAt = refreshAt;
+ this.expiresAt = expiresAt;
+ }
+ }
+}
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..d2f1d1b
--- /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";
+ 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/TokenRefreshException.java b/java/src/main/java/software/amazon/elasticache/auth/TokenRefreshException.java
new file mode 100644
index 0000000..46d40d8
--- /dev/null
+++ b/java/src/main/java/software/amazon/elasticache/auth/TokenRefreshException.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+ * SPDX-License-Identifier: Apache-2.0
+ */
+package software.amazon.elasticache.auth;
+
+/**
+ * Indicates that a cached token could not be refreshed before it expired, or
+ * that its token manager was closed.
+ *
+ * This is an operational failure rather than an invalid-input error, so it
+ * does not extend {@link ToolkitInputException}.
+ */
+public final class TokenRefreshException extends RuntimeException {
+ private static final long serialVersionUID = 1L;
+
+ /**
+ * Creates a token refresh exception.
+ *
+ * @param message actionable error message
+ */
+ public TokenRefreshException(String message) {
+ super(message);
+ }
+
+ /**
+ * Creates a token refresh exception with its underlying refresh failure.
+ *
+ * @param message actionable error message
+ * @param cause last refresh failure
+ */
+ public TokenRefreshException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
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..a681517
--- /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, caching, and refresh for Amazon ElastiCache.
+ */
+package software.amazon.elasticache.auth;
diff --git a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java
new file mode 100644
index 0000000..9e44b00
--- /dev/null
+++ b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java
@@ -0,0 +1,891 @@
+/*
+ * 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.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Comparator;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+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.regions.Region;
+
+class ElastiCacheIamAuthTokenManagerTest {
+ private static final String CACHE = "my-cache";
+ private static final String USER = "testuser";
+ private static final AwsCredentials FIRST_CREDENTIALS = AwsBasicCredentials.create(
+ "AKIAIOSFODNN7EXAMPLE", "first-secret");
+ private static final AwsCredentials ROTATED_CREDENTIALS = AwsBasicCredentials.create(
+ "AKIAI44QH8DHBEXAMPLE", "second-secret");
+ private static final Clock SIGNING_CLOCK =
+ Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneOffset.UTC);
+
+ @Test
+ void cachesTokenUntilBackgroundRefresh() {
+ Harness harness = new Harness();
+ try {
+ String first = harness.manager.getToken();
+ String second = harness.manager.getToken();
+
+ assertEquals(first, second);
+ assertEquals(1, harness.credentialCalls.get());
+ assertEquals(300_000, harness.scheduler.created.get(0).delayMillis);
+ } finally {
+ harness.manager.close();
+ }
+ }
+
+ @Test
+ void generatesFirstTokenLazily() {
+ Harness harness = new Harness();
+ try {
+ assertEquals(0, harness.credentialCalls.get());
+ assertEquals(0, harness.scheduler.createdCount());
+
+ harness.manager.getToken();
+
+ assertEquals(1, harness.credentialCalls.get());
+ assertEquals(1, harness.scheduler.createdCount());
+ } finally {
+ harness.manager.close();
+ }
+ }
+
+ @Test
+ void refreshesInBackgroundWithRotatedCredentials() {
+ Harness harness = new Harness();
+ try {
+ String first = harness.manager.getToken();
+ harness.scheduler.advance(300_000);
+ String second = harness.manager.getToken();
+
+ assertEquals(2, harness.credentialCalls.get());
+ assertNotEquals(first, second);
+ assertEquals(2, harness.changedTokens.size());
+ } finally {
+ harness.manager.close();
+ }
+ }
+
+ @Test
+ void honorsCustomRefreshInterval() {
+ Harness harness = new Harness(Duration.ofSeconds(30), null);
+ try {
+ harness.manager.getToken();
+ harness.scheduler.advance(29_999);
+ assertEquals(1, harness.credentialCalls.get());
+ harness.scheduler.advance(1);
+ assertEquals(2, harness.credentialCalls.get());
+ } finally {
+ harness.manager.close();
+ }
+ }
+
+ @Test
+ void returnsNormalizedCredentialsAndCachedToken() {
+ Harness harness = new Harness(null, "TestUser");
+ try {
+ ElastiCacheIamAuthTokenManager.Credentials credentials =
+ harness.manager.getCredentials();
+
+ assertEquals(USER, credentials.getUserId());
+ assertEquals(credentials.getToken(), harness.manager.getToken());
+ assertEquals(1, harness.credentialCalls.get());
+ } finally {
+ harness.manager.close();
+ }
+ }
+
+ @Test
+ void forcedRefreshUsesRotatedCredentialsAndResetsScheduling() {
+ Harness harness = new Harness();
+ try {
+ String first = harness.manager.getToken();
+ TestTask originalRefresh = harness.scheduler.lastCreated();
+
+ String replacement = harness.manager.refreshToken();
+
+ assertNotEquals(first, replacement);
+ assertEquals(replacement, harness.manager.getToken());
+ assertEquals(2, harness.credentialCalls.get());
+ assertEquals(Arrays.asList(first, replacement), harness.changedTokens);
+ assertTrue(originalRefresh.cancelled);
+ assertEquals(300_000, harness.scheduler.lastCreated().delayMillis);
+ harness.scheduler.advance(299_999);
+ assertEquals(2, harness.credentialCalls.get());
+ harness.scheduler.advance(1);
+ assertEquals(3, harness.credentialCalls.get());
+ } finally {
+ harness.manager.close();
+ }
+ }
+
+ @Test
+ void concurrentForcedRefreshesAndTokenCallersShareReplacement() {
+ TestScheduler scheduler = new TestScheduler();
+ AtomicInteger credentialCalls = new AtomicInteger();
+ CountDownLatch replacementStarted = new CountDownLatch(1);
+ CountDownLatch shared = new CountDownLatch(3);
+ CountDownLatch releaseReplacement = new CountDownLatch(1);
+ ExecutorService callers = Executors.newFixedThreadPool(4);
+ ElastiCacheIamAuthTokenManager manager = ElastiCacheIamAuthTokenManager.builder()
+ .serverlessCacheName(CACHE)
+ .userId(USER)
+ .region(Region.US_EAST_1)
+ .credentialsProvider(() -> {
+ int call = credentialCalls.incrementAndGet();
+ if (call > 1) {
+ replacementStarted.countDown();
+ await(releaseReplacement);
+ }
+ return call == 1 ? FIRST_CREDENTIALS : ROTATED_CREDENTIALS;
+ })
+ .signingClock(SIGNING_CLOCK)
+ .currentTimeMillis(scheduler::now)
+ .scheduler(scheduler)
+ .onSharedRefresh(shared::countDown)
+ .build();
+ try {
+ String rejected = manager.getToken();
+ CompletableFuture first =
+ CompletableFuture.supplyAsync(manager::refreshToken, callers);
+ await(replacementStarted);
+ CompletableFuture second =
+ CompletableFuture.supplyAsync(manager::refreshToken, callers);
+ CompletableFuture token =
+ CompletableFuture.supplyAsync(manager::getToken, callers);
+ CompletableFuture credentials =
+ CompletableFuture.supplyAsync(manager::getCredentials, callers);
+ await(shared);
+ releaseReplacement.countDown();
+
+ String replacement = first.join();
+ assertNotEquals(rejected, replacement);
+ assertEquals(replacement, second.join());
+ assertEquals(replacement, token.join());
+ assertEquals(replacement, credentials.join().getToken());
+ assertEquals(2, credentialCalls.get());
+ } finally {
+ releaseReplacement.countDown();
+ manager.close();
+ callers.shutdownNow();
+ }
+ }
+
+ @Test
+ void forcedRefreshJoinsBackgroundRefreshWithoutStaleOverwrite() {
+ TestScheduler scheduler = new TestScheduler();
+ AtomicInteger credentialCalls = new AtomicInteger();
+ CountDownLatch backgroundStarted = new CountDownLatch(1);
+ CountDownLatch shared = new CountDownLatch(1);
+ CountDownLatch releaseBackground = new CountDownLatch(1);
+ ElastiCacheIamAuthTokenManager manager = ElastiCacheIamAuthTokenManager.builder()
+ .serverlessCacheName(CACHE)
+ .userId(USER)
+ .region(Region.US_EAST_1)
+ .credentialsProvider(() -> {
+ int call = credentialCalls.incrementAndGet();
+ if (call > 1) {
+ backgroundStarted.countDown();
+ await(releaseBackground);
+ }
+ return call == 1 ? FIRST_CREDENTIALS : ROTATED_CREDENTIALS;
+ })
+ .signingClock(SIGNING_CLOCK)
+ .currentTimeMillis(scheduler::now)
+ .scheduler(scheduler)
+ .onSharedRefresh(shared::countDown)
+ .build();
+ try {
+ String rejected = manager.getToken();
+ CompletableFuture background =
+ CompletableFuture.runAsync(() -> scheduler.advance(300_000));
+ await(backgroundStarted);
+ CompletableFuture forced =
+ CompletableFuture.supplyAsync(manager::refreshToken);
+ await(shared);
+ releaseBackground.countDown();
+
+ String replacement = forced.join();
+ background.join();
+ assertNotEquals(rejected, replacement);
+ assertEquals(replacement, manager.getToken());
+ assertEquals(2, credentialCalls.get());
+ assertEquals(2, scheduler.createdCount());
+ } finally {
+ releaseBackground.countDown();
+ manager.close();
+ }
+ }
+
+ @Test
+ void failedForcedRefreshDoesNotReturnInvalidatedToken() {
+ TestScheduler scheduler = new TestScheduler();
+ AtomicInteger credentialCalls = new AtomicInteger();
+ AtomicBoolean failing = new AtomicBoolean();
+ CountDownLatch shared = new CountDownLatch(1);
+ ElastiCacheIamAuthTokenManager manager =
+ baseBuilder(scheduler, credentialCalls, failing)
+ .onSharedRefresh(shared::countDown)
+ .build();
+ try {
+ String rejected = manager.getToken();
+ failing.set(true);
+ CompletableFuture forced = CompletableFuture.supplyAsync(
+ () -> captureFailure(manager::refreshToken));
+ awaitCreatedTasks(scheduler, 2);
+ CompletableFuture concurrent = CompletableFuture.supplyAsync(
+ () -> captureFailure(manager::getToken));
+ await(shared);
+ scheduler.advance(12_000);
+
+ Throwable forcedFailure = forced.join();
+ Throwable concurrentFailure = concurrent.join();
+ assertTrue(forcedFailure instanceof ConfigurationException);
+ assertSame(forcedFailure, concurrentFailure);
+ assertFalse(forcedFailure.getMessage().contains(rejected));
+ assertEquals(9, credentialCalls.get());
+
+ failing.set(false);
+ assertNotEquals(rejected, manager.getToken());
+ assertEquals(10, credentialCalls.get());
+ } finally {
+ manager.close();
+ }
+ }
+
+ @Test
+ void sharesInitialTokenGenerationAcrossConcurrentCallers() {
+ TestScheduler scheduler = new TestScheduler();
+ AtomicInteger credentialCalls = new AtomicInteger();
+ CountDownLatch entered = new CountDownLatch(1);
+ CountDownLatch shared = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ ElastiCacheIamAuthTokenManager manager = ElastiCacheIamAuthTokenManager.builder()
+ .serverlessCacheName(CACHE)
+ .userId(USER)
+ .region(Region.US_EAST_1)
+ .credentialsProvider(() -> {
+ credentialCalls.incrementAndGet();
+ entered.countDown();
+ try {
+ if (!release.await(5, TimeUnit.SECONDS)) {
+ throw new IllegalStateException("timed out waiting to release provider");
+ }
+ } catch (InterruptedException exception) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(exception);
+ }
+ return FIRST_CREDENTIALS;
+ })
+ .signingClock(SIGNING_CLOCK)
+ .currentTimeMillis(scheduler::now)
+ .scheduler(scheduler)
+ .onSharedRefresh(shared::countDown)
+ .build();
+ try {
+ CompletableFuture first =
+ CompletableFuture.supplyAsync(manager::getToken);
+ await(entered);
+ CompletableFuture second =
+ CompletableFuture.supplyAsync(manager::getToken);
+ await(shared);
+ release.countDown();
+
+ assertEquals(first.join(), second.join());
+ assertEquals(1, credentialCalls.get());
+ } finally {
+ release.countDown();
+ manager.close();
+ }
+ }
+
+ @Test
+ void retriesInitialRefreshWithCappedExponentialBackoff() {
+ Harness harness = new Harness();
+ harness.failing.set(true);
+ CompletableFuture result = CompletableFuture.supplyAsync(() -> {
+ try {
+ harness.manager.getToken();
+ return null;
+ } catch (RuntimeException exception) {
+ return exception;
+ }
+ });
+
+ awaitCreatedTasks(harness.scheduler, 1);
+ harness.scheduler.advance(12_000);
+
+ assertTrue(result.join() instanceof ConfigurationException);
+ assertEquals(8, harness.credentialCalls.get());
+ assertEquals(
+ Arrays.asList(100L, 200L, 400L, 800L, 1_600L, 3_200L, 5_000L),
+ harness.scheduler.delays());
+ harness.manager.close();
+ }
+
+ @Test
+ void initialErrorClearsInFlightAndAllowsRetry() {
+ TestScheduler scheduler = new TestScheduler();
+ AtomicInteger credentialCalls = new AtomicInteger();
+ AtomicBoolean throwError = new AtomicBoolean(true);
+ ElastiCacheIamAuthTokenManager manager = ElastiCacheIamAuthTokenManager.builder()
+ .serverlessCacheName(CACHE)
+ .userId(USER)
+ .region(Region.US_EAST_1)
+ .credentialsProvider(() -> {
+ credentialCalls.incrementAndGet();
+ if (throwError.getAndSet(false)) {
+ throw new AssertionError("fatal credential provider failure");
+ }
+ return FIRST_CREDENTIALS;
+ })
+ .signingClock(SIGNING_CLOCK)
+ .currentTimeMillis(scheduler::now)
+ .scheduler(scheduler)
+ .build();
+ try {
+ assertThrows(AssertionError.class, manager::getToken);
+
+ assertFalse(manager.getToken().isEmpty());
+ assertEquals(2, credentialCalls.get());
+ } finally {
+ manager.close();
+ }
+ }
+
+ @Test
+ void waitingCallerReceivesOriginalErrorFromSharedRefresh() {
+ TestScheduler scheduler = new TestScheduler();
+ CountDownLatch entered = new CountDownLatch(1);
+ CountDownLatch shared = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ AssertionError failure = new AssertionError("fatal credential provider failure");
+ ElastiCacheIamAuthTokenManager manager = ElastiCacheIamAuthTokenManager.builder()
+ .serverlessCacheName(CACHE)
+ .userId(USER)
+ .region(Region.US_EAST_1)
+ .credentialsProvider(() -> {
+ entered.countDown();
+ try {
+ if (!release.await(5, TimeUnit.SECONDS)) {
+ throw new IllegalStateException("timed out waiting to release provider");
+ }
+ } catch (InterruptedException exception) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException(exception);
+ }
+ throw failure;
+ })
+ .signingClock(SIGNING_CLOCK)
+ .currentTimeMillis(scheduler::now)
+ .scheduler(scheduler)
+ .onSharedRefresh(shared::countDown)
+ .build();
+ try {
+ CompletableFuture first =
+ CompletableFuture.supplyAsync(() -> captureFailure(manager::getToken));
+ await(entered);
+ CompletableFuture second =
+ CompletableFuture.supplyAsync(() -> captureFailure(manager::getToken));
+ await(shared);
+ release.countDown();
+
+ assertSame(failure, first.join());
+ assertSame(failure, second.join());
+ } finally {
+ release.countDown();
+ manager.close();
+ }
+ }
+
+ @Test
+ void backgroundErrorClearsInFlightAndAllowsLaterRefresh() {
+ TestScheduler scheduler = new TestScheduler();
+ AtomicInteger credentialCalls = new AtomicInteger();
+ AtomicBoolean throwError = new AtomicBoolean();
+ ElastiCacheIamAuthTokenManager manager = ElastiCacheIamAuthTokenManager.builder()
+ .serverlessCacheName(CACHE)
+ .userId(USER)
+ .region(Region.US_EAST_1)
+ .credentialsProvider(() -> {
+ int call = credentialCalls.incrementAndGet();
+ if (throwError.get()) {
+ throw new AssertionError("fatal credential provider failure");
+ }
+ return call == 1 ? FIRST_CREDENTIALS : ROTATED_CREDENTIALS;
+ })
+ .signingClock(SIGNING_CLOCK)
+ .currentTimeMillis(scheduler::now)
+ .scheduler(scheduler)
+ .build();
+ try {
+ String first = manager.getToken();
+ throwError.set(true);
+
+ assertThrows(AssertionError.class, () -> scheduler.advance(300_000));
+
+ throwError.set(false);
+ assertEquals(first, manager.getToken());
+ scheduler.advance(0);
+ assertNotEquals(first, manager.getToken());
+ assertEquals(3, credentialCalls.get());
+ } finally {
+ manager.close();
+ }
+ }
+
+ @Test
+ void servesValidTokenWhileRefreshFailsAndRecovers() {
+ Harness harness = new Harness();
+ try {
+ String token = harness.manager.getToken();
+ harness.failing.set(true);
+ harness.scheduler.advance(312_000);
+
+ assertEquals(5_000, harness.scheduler.lastCreated().delayMillis);
+ int tasksAfterFailure = harness.scheduler.createdCount();
+ assertEquals(token, harness.manager.getToken());
+ assertEquals(token, harness.manager.getToken());
+ assertEquals(9, harness.credentialCalls.get());
+ assertEquals(tasksAfterFailure, harness.scheduler.createdCount());
+
+ harness.failing.set(false);
+ harness.scheduler.advance(5_000);
+ assertNotEquals(token, harness.manager.getToken());
+ } finally {
+ harness.manager.close();
+ }
+ }
+
+ @Test
+ void treatsTokenAsExpiredAtEffectiveLifetime() {
+ Harness harness = new Harness(Duration.ofSeconds(879), null);
+ try {
+ harness.manager.getToken();
+ harness.failing.set(true);
+ // The first failed cycle ends after the 880-second effective expiry
+ // but before the raw 900-second token expiry.
+ harness.scheduler.advance(891_000);
+
+ CompletableFuture result = CompletableFuture.supplyAsync(() -> {
+ try {
+ harness.manager.getToken();
+ return null;
+ } catch (RuntimeException exception) {
+ return exception;
+ }
+ });
+ awaitCreatedTasks(harness.scheduler, 9);
+ harness.scheduler.advance(12_000);
+
+ Throwable error = result.join();
+ assertTrue(error instanceof TokenRefreshException);
+ assertTrue(error.getCause() instanceof ConfigurationException);
+ } finally {
+ harness.manager.close();
+ }
+ }
+
+ @Test
+ void ignoresTokenChangedCallbackFailures() {
+ Harness harness = new Harness(
+ null,
+ null,
+ token -> {
+ throw new AssertionError("consumer callback failed");
+ });
+ try {
+ assertFalse(harness.manager.getToken().isEmpty());
+ assertEquals(1, harness.credentialCalls.get());
+ } finally {
+ harness.manager.close();
+ }
+ }
+
+ @Test
+ void blockingTokenChangedCallbackDoesNotDelayTokenCaller() throws Exception {
+ CountDownLatch callbackEntered = new CountDownLatch(1);
+ CountDownLatch releaseCallback = new CountDownLatch(1);
+ AtomicReference callbackThread = new AtomicReference<>();
+ ElastiCacheIamAuthTokenManager manager = ElastiCacheIamAuthTokenManager.builder()
+ .serverlessCacheName(CACHE)
+ .userId(USER)
+ .region(Region.US_EAST_1)
+ .credentialsProvider(() -> FIRST_CREDENTIALS)
+ .signingClock(SIGNING_CLOCK)
+ .onTokenChanged(token -> {
+ callbackThread.set(Thread.currentThread().getName());
+ callbackEntered.countDown();
+ try {
+ releaseCallback.await();
+ } catch (InterruptedException exception) {
+ Thread.currentThread().interrupt();
+ }
+ })
+ .build();
+ try {
+ CompletableFuture token =
+ CompletableFuture.supplyAsync(manager::getToken);
+
+ await(callbackEntered);
+ assertFalse(token.get(5, TimeUnit.SECONDS).isEmpty());
+ assertEquals("elasticache-iam-token-refresh", callbackThread.get());
+ } finally {
+ releaseCallback.countDown();
+ manager.close();
+ }
+ }
+
+ @Test
+ void closeCancelsBackgroundWorkAndRejectsRequests() {
+ Harness harness = new Harness();
+ harness.manager.getToken();
+ harness.manager.close();
+ harness.scheduler.advance(300_000);
+
+ assertEquals(1, harness.credentialCalls.get());
+ assertThrows(TokenRefreshException.class, harness.manager::getToken);
+ assertThrows(TokenRefreshException.class, harness.manager::refreshToken);
+ }
+
+ @Test
+ void closeCancelsForcedRefreshInFlight() {
+ TestScheduler scheduler = new TestScheduler();
+ CountDownLatch replacementStarted = new CountDownLatch(1);
+ CountDownLatch releaseReplacement = new CountDownLatch(1);
+ AtomicInteger credentialCalls = new AtomicInteger();
+ ElastiCacheIamAuthTokenManager manager = ElastiCacheIamAuthTokenManager.builder()
+ .serverlessCacheName(CACHE)
+ .userId(USER)
+ .region(Region.US_EAST_1)
+ .credentialsProvider(() -> {
+ int call = credentialCalls.incrementAndGet();
+ if (call > 1) {
+ replacementStarted.countDown();
+ await(releaseReplacement);
+ }
+ return call == 1 ? FIRST_CREDENTIALS : ROTATED_CREDENTIALS;
+ })
+ .signingClock(SIGNING_CLOCK)
+ .currentTimeMillis(scheduler::now)
+ .scheduler(scheduler)
+ .build();
+ try {
+ manager.getToken();
+ CompletableFuture result = CompletableFuture.supplyAsync(
+ () -> captureFailure(manager::refreshToken));
+ await(replacementStarted);
+ manager.close();
+ releaseReplacement.countDown();
+
+ Throwable failure = result.join();
+ assertTrue(failure instanceof TokenRefreshException);
+ assertEquals(
+ "Token refresh was cancelled because the token manager was closed.",
+ failure.getMessage());
+ assertEquals(1, scheduler.createdCount());
+ } finally {
+ releaseReplacement.countDown();
+ manager.close();
+ }
+ }
+
+ @Test
+ void closePreventsRetryWhenInFlightGenerationFails() {
+ TestScheduler scheduler = new TestScheduler();
+ CountDownLatch entered = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ ElastiCacheIamAuthTokenManager manager = ElastiCacheIamAuthTokenManager.builder()
+ .serverlessCacheName(CACHE)
+ .userId(USER)
+ .region(Region.US_EAST_1)
+ .credentialsProvider(() -> {
+ entered.countDown();
+ await(release);
+ throw new IllegalStateException("credential chain failed");
+ })
+ .signingClock(SIGNING_CLOCK)
+ .currentTimeMillis(scheduler::now)
+ .scheduler(scheduler)
+ .build();
+
+ CompletableFuture result = CompletableFuture.supplyAsync(() -> {
+ try {
+ manager.getToken();
+ return null;
+ } catch (RuntimeException exception) {
+ return exception;
+ }
+ });
+ await(entered);
+ manager.close();
+ release.countDown();
+
+ Throwable failure = result.join();
+ assertTrue(failure instanceof TokenRefreshException);
+ assertEquals(
+ "Token refresh was cancelled because the token manager was closed.",
+ failure.getMessage());
+ assertEquals(0, scheduler.createdCount());
+ }
+
+ @Test
+ void closeAfterSigningPreventsTokenInstallationAndNotification() {
+ TestScheduler scheduler = new TestScheduler();
+ List changedTokens = new ArrayList<>();
+ CountDownLatch signed = new CountDownLatch(1);
+ CountDownLatch install = new CountDownLatch(1);
+ ElastiCacheIamAuthTokenManager manager =
+ baseBuilder(scheduler, new AtomicInteger(), new AtomicBoolean())
+ .onTokenChanged(changedTokens::add)
+ .beforeInstall(() -> {
+ signed.countDown();
+ await(install);
+ })
+ .build();
+ CompletableFuture result =
+ CompletableFuture.supplyAsync(() -> captureFailure(manager::getToken));
+
+ await(signed);
+ manager.close();
+ install.countDown();
+
+ Throwable failure = result.join();
+ assertTrue(failure instanceof TokenRefreshException);
+ assertEquals(
+ "Token refresh was cancelled because the token manager was closed.",
+ failure.getMessage());
+ assertTrue(changedTokens.isEmpty());
+ assertEquals(0, scheduler.createdCount());
+ }
+
+ @Test
+ void validatesRefreshInterval() {
+ for (Duration value : Arrays.asList(
+ Duration.ZERO,
+ Duration.ofMillis(-1),
+ Duration.ofNanos(1),
+ Duration.ofSeconds(880),
+ Duration.ofSeconds(899),
+ Duration.ofMinutes(15),
+ Duration.ofMinutes(16))) {
+ assertThrows(
+ InvalidParameterException.class,
+ () -> baseBuilder(new TestScheduler(), new AtomicInteger(), new AtomicBoolean())
+ .refreshAfter(value)
+ .build());
+ }
+
+ ElastiCacheIamAuthTokenManager manager =
+ baseBuilder(new TestScheduler(), new AtomicInteger(), new AtomicBoolean())
+ .refreshAfter(Duration.ofSeconds(879))
+ .build();
+ manager.close();
+ }
+
+ @Test
+ void refreshExceptionIsNotAnInputException() {
+ assertFalse(ToolkitInputException.class.isAssignableFrom(TokenRefreshException.class));
+ }
+
+ private static ElastiCacheIamAuthTokenManager.Builder baseBuilder(
+ TestScheduler scheduler,
+ AtomicInteger credentialCalls,
+ AtomicBoolean failing) {
+ return ElastiCacheIamAuthTokenManager.builder()
+ .serverlessCacheName(CACHE)
+ .userId(USER)
+ .region(Region.US_EAST_1)
+ .credentialsProvider(() -> {
+ int call = credentialCalls.incrementAndGet();
+ if (failing.get()) {
+ throw new IllegalStateException("credential chain failed");
+ }
+ return call == 1 ? FIRST_CREDENTIALS : ROTATED_CREDENTIALS;
+ })
+ .signingClock(SIGNING_CLOCK)
+ .currentTimeMillis(scheduler::now)
+ .random(() -> 0.5)
+ .scheduler(scheduler);
+ }
+
+ private static void awaitCreatedTasks(TestScheduler scheduler, int expected) {
+ assertTrue(
+ scheduler.awaitCreatedCount(expected, 5, TimeUnit.SECONDS),
+ "timed out waiting for scheduled tasks");
+ assertEquals(expected, scheduler.createdCount());
+ }
+
+ private static void await(CountDownLatch latch) {
+ try {
+ assertTrue(latch.await(5, TimeUnit.SECONDS), "timed out waiting for latch");
+ } catch (InterruptedException exception) {
+ Thread.currentThread().interrupt();
+ throw new AssertionError("interrupted while waiting for latch", exception);
+ }
+ }
+
+ private static Throwable captureFailure(Runnable action) {
+ try {
+ action.run();
+ return null;
+ } catch (Throwable failure) {
+ return failure;
+ }
+ }
+
+ private static final class Harness {
+ private final TestScheduler scheduler = new TestScheduler();
+ private final AtomicInteger credentialCalls = new AtomicInteger();
+ private final AtomicBoolean failing = new AtomicBoolean();
+ private final List changedTokens = new ArrayList<>();
+ private final ElastiCacheIamAuthTokenManager manager;
+
+ private Harness() {
+ this(null, null);
+ }
+
+ private Harness(Duration refreshAfter, String userId) {
+ this(refreshAfter, userId, null);
+ }
+
+ private Harness(
+ Duration refreshAfter, String userId, java.util.function.Consumer callback) {
+ ElastiCacheIamAuthTokenManager.Builder builder =
+ baseBuilder(scheduler, credentialCalls, failing)
+ .userId(userId == null ? USER : userId)
+ .onTokenChanged(callback == null ? changedTokens::add : callback);
+ if (refreshAfter != null) {
+ builder.refreshAfter(refreshAfter);
+ }
+ manager = builder.build();
+ }
+ }
+
+ private static final class TestScheduler
+ implements ElastiCacheIamAuthTokenManager.Scheduler {
+ private long time;
+ private final List pending = new ArrayList<>();
+ private final List created = new ArrayList<>();
+
+ private synchronized long now() {
+ return time;
+ }
+
+ @Override
+ public synchronized ElastiCacheIamAuthTokenManager.Cancellable schedule(
+ Runnable task, long delayMillis) {
+ TestTask scheduled = new TestTask(time + delayMillis, delayMillis, task);
+ pending.add(scheduled);
+ created.add(scheduled);
+ notifyAll();
+ return scheduled;
+ }
+
+ @Override
+ public void execute(Runnable task) {
+ task.run();
+ }
+
+ @Override
+ public void close() {}
+
+ private synchronized void advance(long milliseconds) {
+ long target = time + milliseconds;
+ while (true) {
+ TestTask next = pending.stream()
+ .filter(task -> !task.cancelled && task.at <= target)
+ .min(Comparator.comparingLong(task -> task.at))
+ .orElse(null);
+ if (next == null) {
+ break;
+ }
+ pending.remove(next);
+ time = next.at;
+ next.run();
+ }
+ time = target;
+ }
+
+ private synchronized List delays() {
+ List delays = new ArrayList<>();
+ for (TestTask task : created) {
+ delays.add(task.delayMillis);
+ }
+ return delays;
+ }
+
+ private synchronized TestTask lastCreated() {
+ return created.get(created.size() - 1);
+ }
+
+ private synchronized int createdCount() {
+ return created.size();
+ }
+
+ private synchronized boolean awaitCreatedCount(
+ int expected, long timeout, TimeUnit unit) {
+ long remainingNanos = unit.toNanos(timeout);
+ long deadline = System.nanoTime() + remainingNanos;
+ while (created.size() < expected && remainingNanos > 0) {
+ try {
+ TimeUnit.NANOSECONDS.timedWait(this, remainingNanos);
+ } catch (InterruptedException exception) {
+ Thread.currentThread().interrupt();
+ return false;
+ }
+ remainingNanos = deadline - System.nanoTime();
+ }
+ return created.size() >= expected;
+ }
+ }
+
+ private static final class TestTask
+ implements ElastiCacheIamAuthTokenManager.Cancellable {
+ private final long at;
+ private final long delayMillis;
+ private final Runnable task;
+ private boolean cancelled;
+
+ private TestTask(long at, long delayMillis, Runnable task) {
+ this.at = at;
+ this.delayMillis = delayMillis;
+ this.task = task;
+ }
+
+ @Override
+ public void cancel() {
+ cancelled = true;
+ }
+
+ private void run() {
+ if (!cancelled) {
+ task.run();
+ }
+ }
+ }
+}
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/node/.gitignore b/node/.gitignore
new file mode 100644
index 0000000..cc1b485
--- /dev/null
+++ b/node/.gitignore
@@ -0,0 +1,15 @@
+# Build artifacts
+dist/
+dist-test/
+node_modules/
+
+# npm
+*.tgz
+pack/
+npm-debug.log*
+
+# Test / coverage output
+coverage/
+
+# OS / editor
+.DS_Store
diff --git a/node/.prettierignore b/node/.prettierignore
new file mode 100644
index 0000000..a32a6f6
--- /dev/null
+++ b/node/.prettierignore
@@ -0,0 +1,4 @@
+dist/
+dist-test/
+node_modules/
+package-lock.json
diff --git a/node/.prettierrc.json b/node/.prettierrc.json
new file mode 100644
index 0000000..e020f03
--- /dev/null
+++ b/node/.prettierrc.json
@@ -0,0 +1,6 @@
+{
+ "printWidth": 90,
+ "proseWrap": "always",
+ "singleQuote": false,
+ "trailingComma": "all"
+}
diff --git a/node/CHANGELOG.md b/node/CHANGELOG.md
new file mode 100644
index 0000000..ca50054
--- /dev/null
+++ b/node/CHANGELOG.md
@@ -0,0 +1,23 @@
+# Changelog
+
+All notable changes to the Node.js package are documented here. The format follows
+[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the package uses
+[Semantic Versioning](https://semver.org/).
+
+## [1.0.0] - 2026-10-21
+
+### Added
+
+- `generateIamAuthToken()` for signing IAM authentication tokens for Amazon ElastiCache
+ serverless caches and node-based replication groups.
+- `ElastiCacheIAMAuthTokenProvider` for generating fresh tokens when clients connect or
+ reconnect.
+- `ElastiCacheIAMAuthTokenManager` for caching a token, refreshing it in the background
+ before it expires, retrying transient refresh failures, and notifying an
+ `onTokenChanged` callback. Its no-argument `getCredentials()` hook returns the user id
+ and current token together for client credential-provider integrations.
+- `ToolkitInputError`, `InvalidParameterError`, `ConfigurationError`, and
+ `TokenRefreshError`.
+- Runnable token-generation and iovalkey integration examples under `examples/`.
+
+[1.0.0]: https://github.com/aws/developer-toolkit-elasticache/releases/tag/node-v1.0.0
diff --git a/node/LICENSE b/node/LICENSE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/node/LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License 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.
diff --git a/node/NOTICE b/node/NOTICE
new file mode 100644
index 0000000..e8a6095
--- /dev/null
+++ b/node/NOTICE
@@ -0,0 +1,2 @@
+Developer Toolkit for Amazon ElastiCache
+Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
diff --git a/node/README.md b/node/README.md
new file mode 100644
index 0000000..bcc7f82
--- /dev/null
+++ b/node/README.md
@@ -0,0 +1,233 @@
+# Developer Toolkit for Amazon ElastiCache (Node.js)
+
+This package generates IAM authentication tokens for Amazon ElastiCache serverless caches
+and node-based replication groups. It provides a typed TypeScript API, an async token
+provider, and a caching token manager that refreshes tokens in the background.
+
+## Installation
+
+The package requires Node.js 22 or newer. To install the current source package:
+
+```bash
+git clone https://github.com/aws/developer-toolkit-elasticache.git
+cd developer-toolkit-elasticache/node
+npm ci
+npm run build
+npm install .
+```
+
+For a published package, install it with npm:
+
+```bash
+npm install @aws/developer-toolkit-elasticache
+```
+
+## Usage
+
+The cache must have TLS enabled and an IAM-enabled user with access to the cache. The AWS
+identity used by the credential chain must have the `elasticache:Connect` permission.
+
+### Generate a token
+
+```typescript
+import { generateIamAuthToken } from "@aws/developer-toolkit-elasticache";
+
+const token = await generateIamAuthToken({
+ serverlessCacheName: "my-cache",
+ // Use replicationGroupId: "my-group" for a node-based replication group.
+ userId: "iam-user",
+ region: "us-east-1",
+});
+```
+
+Provide exactly one of `serverlessCacheName` and `replicationGroupId`. The value is the
+ElastiCache cache name or replication group id used as the SigV4 signing host; it is not
+the connection endpoint DNS name. Cache names and user ids are normalized to lowercase
+before validation and signing.
+
+The `region` option is optional when a region is available through the standard AWS
+configuration chain. If no region can be resolved, the operation throws
+`ConfigurationError`.
+
+1. The explicit `region` option.
+2. `AWS_REGION`.
+3. `AWS_DEFAULT_REGION`.
+4. The region in the standard AWS shared configuration.
+
+Credentials use the standard Node.js AWS provider chain, including environment variables,
+shared config and credentials files, SSO, assume-role, container credentials, and EC2
+instance metadata. One-shot calls resolve the region and credentials for each invocation,
+so refreshed configuration and credentials are used.
+
+Token generation cannot detect expired or otherwise invalid credentials: signing may
+succeed, but ElastiCache will reject the connection, for example with `WRONGPASS`.
+
+### Reconnecting clients
+
+`ElastiCacheIAMAuthTokenProvider` is client-agnostic. It resolves and retains the region
+once during construction, exposes the normalized `userId`, and asynchronously vends a
+fresh token each time `getToken()` is called. Credential providers are still invoked for
+every token, so refreshed credentials are used without changing the signing region.
+
+Node's region configuration is asynchronous, so use the `create()` factory to surface a
+missing region up front. The synchronous constructor is also supported, but it retains the
+configuration error and reports it from the first `getToken()` call instead.
+
+```typescript
+import { ElastiCacheIAMAuthTokenProvider } from "@aws/developer-toolkit-elasticache";
+
+const auth = await ElastiCacheIAMAuthTokenProvider.create({
+ serverlessCacheName: "my-cache",
+ userId: "iam-user",
+ region: "us-east-1",
+});
+
+const username = auth.userId;
+const password = await auth.getToken();
+```
+
+Pass `username` and `password` to the client connection configuration. Fetch another token
+whenever the client creates a new connection.
+
+### Caching and background refresh
+
+`ElastiCacheIAMAuthTokenProvider` is stateless: every `getToken()` call resolves
+credentials and signs a new token. `ElastiCacheIAMAuthTokenManager` adds caching on top of
+that:
+
+| | `ElastiCacheIAMAuthTokenProvider` | `ElastiCacheIAMAuthTokenManager` |
+| ---------------------- | --------------------------------- | --------------------------------------------------- |
+| Token per `getToken()` | A freshly signed token every call | The cached token while it is valid |
+| Refresh | Caller-driven | Background, at `refreshAfterSeconds` after issuance |
+| Refresh failures | Surfaced to the caller | Retried, with the current token still served |
+| Change notification | None | `onTokenChanged` |
+| Cleanup | None needed | `close()` |
+
+Use the manager when a client reconnects often, or asks for a token more often than the
+900 second token lifetime, and use the provider when each call should mint a new token.
+
+```typescript
+import { ElastiCacheIAMAuthTokenManager } from "@aws/developer-toolkit-elasticache";
+
+const auth = await ElastiCacheIAMAuthTokenManager.create({
+ serverlessCacheName: "my-cache",
+ userId: "iam-user",
+ region: "us-east-1",
+ // Optional; defaults to 300. Must be greater than 0 and less than 880.
+ refreshAfterSeconds: 300,
+ onTokenChanged: () => console.log("installed a new IAM auth token"),
+});
+
+try {
+ const [username, password] = await auth.getCredentials();
+} finally {
+ auth.close();
+}
+```
+
+`getCredentials()` is the no-argument provider hook for integrations that expect the user
+id and token together. `getToken()` remains available when the client accepts the username
+and password separately.
+
+The region is resolved when the manager is created, but credentials are not resolved and
+no token is signed until the first `getToken()` or `getCredentials()` call. After that,
+`getToken()` returns the cached token immediately while it is valid, starting a background
+refresh once the token is past its refresh point. Concurrent callers with no valid token
+share a single refresh.
+
+A token is served for 880 seconds, a 20 second margin below the 900 second token lifetime,
+so a cached token is never handed to a client right at the edge of its real expiry.
+`refreshAfterSeconds` must stay below that 880 second effective lifetime.
+
+A refresh makes up to 8 attempts, the first immediate and the rest with jittered
+exponential backoff capped at 5 seconds. While the current token is still valid, a failed
+refresh cycle is invisible to callers: the cached token is returned and another refresh
+attempt is scheduled 5 seconds later. Callers that ask for a token during that 5 second
+cooldown keep getting the valid cached token without starting a redundant refresh cycle of
+their own; a successful refresh clears the cooldown. `getToken()` throws
+`TokenRefreshError` only if the cached token has expired before a refresh could replace
+it. The first token is different: its failure is reported as the underlying
+`ConfigurationError` or `InvalidParameterError`.
+
+Call `refreshToken()` to force a new token immediately instead of waiting for the next
+scheduled background refresh, for example after the client reports an authentication
+failure. It invalidates the cached token and mints a replacement, sharing that refresh
+with any concurrent `getToken()` or `refreshToken()` caller, including a background
+refresh already under way. Like `getToken()`, it throws `TokenRefreshError` if the manager
+is closed, and reports a first-mint-style failure if it could not produce a replacement.
+
+```typescript
+// From the client's authentication-failure handling path:
+const replacementPassword = await auth.refreshToken();
+// Supply replacementPassword on the next connection attempt.
+```
+
+The manager's timers are unreferenced, so they will not keep a process alive on their own.
+Call `close()` when the manager is no longer needed; afterwards `getToken()` and
+`refreshToken()` throw.
+
+See [`examples/connect-iovalkey.mjs`](examples/connect-iovalkey.mjs) for an iovalkey
+integration, and [`examples/connect-node-redis.mjs`](examples/connect-node-redis.mjs) for
+a node-redis integration using `credentialsProvider`.
+
+### Examples
+
+The source repository includes runnable ESM examples for Node.js 22 and newer. After
+installing the package and configuring AWS credentials, run:
+
+```bash
+node examples/generate-token.mjs
+```
+
+The iovalkey example demonstrates integration with a client that does not provide built-in
+ElastiCache IAM authentication, reconnecting manually on each new connection. The
+node-redis example demonstrates a client with a native `credentialsProvider` hook instead.
+Both clients are optional dependencies and are not added to this package's runtime
+dependencies. Install one in the application that runs its example, then run:
+
+```bash
+npm install iovalkey
+node examples/connect-iovalkey.mjs
+```
+
+```bash
+npm install redis
+node examples/connect-node-redis.mjs
+```
+
+## Errors
+
+The public error hierarchy is:
+
+- `ToolkitInputError`: base class for user-correctable failures.
+- `InvalidParameterError`: invalid values or target combinations.
+- `ConfigurationError`: missing AWS region or credentials.
+
+`TokenRefreshError` is an operational failure rather than an input error, so it extends
+`Error` directly. It is only thrown by `ElastiCacheIAMAuthTokenManager`, when a cached
+token expired before a refresh could replace it or when the manager is closed. When a
+refresh failed, its `cause` is the error from the last refresh attempt.
+
+## Security
+
+An IAM auth token is a short-lived bearer credential. Do not log, persist, or place it in
+shell history. Connect to ElastiCache over TLS. When using a command-line client, pass the
+token through `REDISCLI_AUTH` or `VALKEYCLI_AUTH` instead of a password argument that may
+appear in the process list. Generate the token programmatically with
+`generateIamAuthToken()` and avoid logging or persisting it.
+
+## Development
+
+```bash
+npm ci
+npm run lint
+npm run format:check
+npm run typecheck
+npm test
+npm run build
+npm run package:check
+```
+
+## License
+
+Apache-2.0. See [LICENSE](LICENSE).
diff --git a/node/eslint.config.mjs b/node/eslint.config.mjs
new file mode 100644
index 0000000..6f0112e
--- /dev/null
+++ b/node/eslint.config.mjs
@@ -0,0 +1,23 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import eslint from "@eslint/js";
+import tseslint from "typescript-eslint";
+
+export default tseslint.config(
+ {
+ ignores: ["dist/", "dist-test/", "node_modules/"],
+ },
+ eslint.configs.recommended,
+ ...tseslint.configs.recommended,
+ {
+ files: ["**/*.ts", "**/*.mjs"],
+ rules: {
+ "@typescript-eslint/no-explicit-any": "error",
+ "@typescript-eslint/no-unused-vars": [
+ "error",
+ { argsIgnorePattern: "^_", varsIgnorePattern: "^_" },
+ ],
+ },
+ },
+);
diff --git a/node/examples/connect-iovalkey.mjs b/node/examples/connect-iovalkey.mjs
new file mode 100644
index 0000000..b7d2dee
--- /dev/null
+++ b/node/examples/connect-iovalkey.mjs
@@ -0,0 +1,66 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+/* global console */
+
+/**
+ * Run this example after installing iovalkey:
+ *
+ * npm install iovalkey
+ *
+ * The cache name used for signing is different from the connection endpoint.
+ * For a node-based deployment, use replicationGroupId instead.
+ *
+ * iovalkey does not generate ElastiCache IAM authentication tokens, and it
+ * cannot re-authenticate an existing connection. This example uses
+ * ElastiCacheIAMAuthTokenManager, which caches one token and refreshes it in
+ * the background, so every new client is created with a valid token without
+ * signing a new one per connection. Automatic reconnects are disabled because
+ * they would reuse the token the connection was opened with; long-running
+ * applications should create a replacement client, calling getCredentials()
+ * again, whenever the connection is lost.
+ */
+import { Valkey } from "iovalkey";
+import { ElastiCacheIAMAuthTokenManager } from "@aws/developer-toolkit-elasticache";
+
+const ENDPOINT = "my-cache-abc123.serverless.use1.cache.amazonaws.com";
+
+const auth = await ElastiCacheIAMAuthTokenManager.create({
+ serverlessCacheName: "my-cache",
+ userId: "my-iam-user",
+ region: "us-east-1",
+ // Never log the token itself; it is a bearer credential.
+ onTokenChanged: () => console.log("installed a new IAM auth token"),
+});
+
+async function connect() {
+ const [username, password] = await auth.getCredentials();
+ return new Valkey({
+ host: ENDPOINT,
+ port: 6379,
+ username,
+ password,
+ tls: {},
+ retryStrategy: () => null,
+ });
+}
+
+try {
+ const client = await connect();
+ try {
+ await client.set("hello", "world");
+ console.log(await client.get("hello"));
+ } finally {
+ client.disconnect();
+ }
+
+ // A second connection reuses the cached token instead of signing another one.
+ const replacement = await connect();
+ try {
+ console.log(await replacement.get("hello"));
+ } finally {
+ replacement.disconnect();
+ }
+} finally {
+ auth.close();
+}
diff --git a/node/examples/connect-node-redis.mjs b/node/examples/connect-node-redis.mjs
new file mode 100644
index 0000000..c7d1ff4
--- /dev/null
+++ b/node/examples/connect-node-redis.mjs
@@ -0,0 +1,56 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+/* global console */
+
+/**
+ * Run this example after installing redis:
+ *
+ * npm install redis
+ *
+ * The cache name used for signing is different from the connection endpoint.
+ * For a node-based deployment, use replicationGroupId instead.
+ *
+ * node-redis calls an AsyncCredentialsProvider's credentials() on every
+ * connection handshake, including its own automatic reconnects. Backing it
+ * with ElastiCacheIAMAuthTokenManager.getCredentials() means every handshake
+ * gets a valid cached token without signing a new one per connection, and
+ * node-redis's built-in reconnect strategy can stay enabled.
+ */
+import { createClient } from "redis";
+import { ElastiCacheIAMAuthTokenManager } from "@aws/developer-toolkit-elasticache";
+
+const ENDPOINT = "my-cache-abc123.serverless.use1.cache.amazonaws.com";
+
+const auth = await ElastiCacheIAMAuthTokenManager.create({
+ serverlessCacheName: "my-cache",
+ userId: "my-iam-user",
+ region: "us-east-1",
+ // Never log the token itself; it is a bearer credential.
+ onTokenChanged: () => console.log("installed a new IAM auth token"),
+});
+
+const client = createClient({
+ socket: {
+ host: ENDPOINT,
+ port: 6379,
+ tls: true,
+ },
+ credentialsProvider: {
+ type: "async-credentials-provider",
+ credentials: async () => {
+ const [username, password] = await auth.getCredentials();
+ return { username, password };
+ },
+ },
+});
+client.on("error", (error) => console.error("node-redis client error", error));
+
+try {
+ await client.connect();
+ await client.set("hello", "world");
+ console.log(await client.get("hello"));
+} finally {
+ await client.close();
+ auth.close();
+}
diff --git a/node/examples/generate-token.mjs b/node/examples/generate-token.mjs
new file mode 100644
index 0000000..d9ad0e8
--- /dev/null
+++ b/node/examples/generate-token.mjs
@@ -0,0 +1,15 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+/* global console */
+
+import { generateIamAuthToken } from "@aws/developer-toolkit-elasticache";
+
+const token = await generateIamAuthToken({
+ serverlessCacheName: "my-cache",
+ userId: "my-iam-user",
+ region: "us-east-1",
+});
+
+// Do not print the token. It is a short-lived bearer credential.
+console.log(`Generated an IAM auth token (${token.length} characters).`);
diff --git a/node/package-lock.json b/node/package-lock.json
new file mode 100644
index 0000000..d6d96f1
--- /dev/null
+++ b/node/package-lock.json
@@ -0,0 +1,1944 @@
+{
+ "name": "@aws/developer-toolkit-elasticache",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@aws/developer-toolkit-elasticache",
+ "version": "1.0.0",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/credential-providers": "^3.943.0",
+ "@smithy/core": "^3.18.0",
+ "@smithy/hash-node": "^4.2.7",
+ "@smithy/signature-v4": "^5.2.7",
+ "@smithy/types": "^4.9.0"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.39.1",
+ "@types/node": "^24.10.1",
+ "eslint": "^9.39.1",
+ "prettier": "^3.7.4",
+ "typescript": "^5.9.3",
+ "typescript-eslint": "^8.46.3"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/@aws-sdk/core": {
+ "version": "3.977.9",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz",
+ "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.974.5",
+ "@aws-sdk/xml-builder": "^3.972.40",
+ "@aws/lambda-invoke-store": "^0.3.0",
+ "@smithy/core": "^3.33.3",
+ "@smithy/signature-v4": "^5.6.12",
+ "@smithy/types": "^4.17.2",
+ "bowser": "^2.11.0",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-cognito-identity": {
+ "version": "3.972.69",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.69.tgz",
+ "integrity": "sha512-vpsh9VWmQVC/nsdzf72F2yUMBOFT1aq+hoLseYV03zjVXVHkjqSNJskFRKPFxc3MRFrHNbSSmOwsbYE15u9ecw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/nested-clients": "^3.997.44",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-env": {
+ "version": "3.972.70",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz",
+ "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-http": {
+ "version": "3.972.72",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz",
+ "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/fetch-http-handler": "^5.7.2",
+ "@smithy/node-http-handler": "^4.11.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-ini": {
+ "version": "3.973.15",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz",
+ "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/credential-provider-env": "^3.972.70",
+ "@aws-sdk/credential-provider-http": "^3.972.72",
+ "@aws-sdk/credential-provider-login": "^3.972.77",
+ "@aws-sdk/credential-provider-process": "^3.972.70",
+ "@aws-sdk/credential-provider-sso": "^3.973.14",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.76",
+ "@aws-sdk/nested-clients": "^3.997.44",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/credential-provider-imds": "^4.4.16",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-login": {
+ "version": "3.972.77",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz",
+ "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/nested-clients": "^3.997.44",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-node": {
+ "version": "3.972.82",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.82.tgz",
+ "integrity": "sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/credential-provider-env": "^3.972.70",
+ "@aws-sdk/credential-provider-http": "^3.972.72",
+ "@aws-sdk/credential-provider-ini": "^3.973.15",
+ "@aws-sdk/credential-provider-process": "^3.972.70",
+ "@aws-sdk/credential-provider-sso": "^3.973.14",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.76",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/credential-provider-imds": "^4.4.16",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-process": {
+ "version": "3.972.70",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz",
+ "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-sso": {
+ "version": "3.973.14",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz",
+ "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/nested-clients": "^3.997.44",
+ "@aws-sdk/token-providers": "3.1116.0",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-provider-web-identity": {
+ "version": "3.972.76",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz",
+ "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/nested-clients": "^3.997.44",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/credential-providers": {
+ "version": "3.1123.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1123.0.tgz",
+ "integrity": "sha512-hfpS7dywjHDyzDRLl/IF03Uy4FY9MpnPT7eSArkvTwP4F29G1r3PYwNyhCTa+wX/pyeyuVKkXX44XeQlWDiMJQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/credential-provider-cognito-identity": "^3.972.69",
+ "@aws-sdk/credential-provider-env": "^3.972.70",
+ "@aws-sdk/credential-provider-http": "^3.972.72",
+ "@aws-sdk/credential-provider-ini": "^3.973.15",
+ "@aws-sdk/credential-provider-login": "^3.972.77",
+ "@aws-sdk/credential-provider-node": "^3.972.82",
+ "@aws-sdk/credential-provider-process": "^3.972.70",
+ "@aws-sdk/credential-provider-sso": "^3.973.14",
+ "@aws-sdk/credential-provider-web-identity": "^3.972.76",
+ "@aws-sdk/nested-clients": "^3.997.44",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/credential-provider-imds": "^4.4.16",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/nested-clients": {
+ "version": "3.997.44",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz",
+ "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/signature-v4-multi-region": "^3.996.46",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/fetch-http-handler": "^5.7.2",
+ "@smithy/node-http-handler": "^4.11.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/signature-v4-multi-region": {
+ "version": "3.996.46",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz",
+ "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/signature-v4": "^5.6.12",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/token-providers": {
+ "version": "3.1116.0",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz",
+ "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@aws-sdk/core": "^3.977.9",
+ "@aws-sdk/nested-clients": "^3.997.44",
+ "@aws-sdk/types": "^3.974.5",
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/types": {
+ "version": "3.974.5",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz",
+ "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws-sdk/xml-builder": {
+ "version": "3.972.40",
+ "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz",
+ "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@aws/lambda-invoke-store": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz",
+ "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.10.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
+ "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.21.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz",
+ "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^2.1.7",
+ "debug": "^4.3.1",
+ "minimatch": "^3.1.5"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.4.2",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz",
+ "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz",
+ "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/eslintrc": {
+ "version": "3.3.7",
+ "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz",
+ "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^6.14.0",
+ "debug": "^4.3.2",
+ "espree": "^10.0.1",
+ "globals": "^14.0.0",
+ "ignore": "^5.2.0",
+ "import-fresh": "^3.2.1",
+ "js-yaml": "^4.3.2",
+ "minimatch": "^3.1.5",
+ "strip-json-comments": "^3.1.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "9.39.5",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz",
+ "integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "2.1.7",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz",
+ "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz",
+ "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^0.17.0",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/types": "^0.15.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.2",
+ "@humanfs/types": "^0.15.0",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/types": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@smithy/core": {
+ "version": "3.33.3",
+ "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz",
+ "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/credential-provider-imds": {
+ "version": "4.5.2",
+ "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz",
+ "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.33.2",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/fetch-http-handler": {
+ "version": "5.7.2",
+ "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.2.tgz",
+ "integrity": "sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.33.2",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/hash-node": {
+ "version": "4.5.2",
+ "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.5.2.tgz",
+ "integrity": "sha512-OcD8fGClTkP0BWHVEAgUp1RZyCw8cKfqTPQ+DgrSF5jvR8zKkw2Aud79L4G/1Fu3QKLcsHExxRIPQCcKx7+xkg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.33.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/node-http-handler": {
+ "version": "4.12.0",
+ "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.0.tgz",
+ "integrity": "sha512-0mq1pHadfyXCYCqm2cNpbjNIT+fbaUpNxewZb/YNr2L0IrEVMOb8gM/Fl4K6XvHCW3uSNDFwPl/+iKm0bx9jYg==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/signature-v4": {
+ "version": "5.7.3",
+ "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz",
+ "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@smithy/core": "^3.33.3",
+ "@smithy/types": "^4.17.2",
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@smithy/types": {
+ "version": "4.17.2",
+ "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.2.tgz",
+ "integrity": "sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "tslib": "^2.6.2"
+ },
+ "engines": {
+ "node": ">=18.0.0"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "24.13.3",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
+ "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.18.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.69.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.69.0.tgz",
+ "integrity": "sha512-t5jQTKPIgVW1PE6dR6H6Qz5gm8zjMlX5/2gRaOGd9eO6V7J+tQc6iWKukEe7dY8u9HyYasQ0yfF0/FSSTEO2gA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.69.0",
+ "@typescript-eslint/type-utils": "8.69.0",
+ "@typescript-eslint/utils": "8.69.0",
+ "@typescript-eslint/visitor-keys": "8.69.0",
+ "ignore": "^7.0.5",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.69.0",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.8",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz",
+ "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.69.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.69.0.tgz",
+ "integrity": "sha512-l4b0DhWioGg6Gt2ebGlvfkFMOjRsauxtsnDRwUSRX1qHq3HdTfQHV8wW9zEXeciai6HfeaKOedQn2Zoofx3WBw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.69.0",
+ "@typescript-eslint/types": "8.69.0",
+ "@typescript-eslint/typescript-estree": "8.69.0",
+ "@typescript-eslint/visitor-keys": "8.69.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.69.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.69.0.tgz",
+ "integrity": "sha512-yi4obFrHMmnsesWehHbkg9zMA7Jt8cXT+mKM08G999pH1yT6nqgsHx7MYm0uY1wAj8CqiBXYRJ7WAT0QdQHQXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.69.0",
+ "@typescript-eslint/types": "^8.69.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.69.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.69.0.tgz",
+ "integrity": "sha512-ewfspqWvSxKSOaplqAUNbaSFO0eB6w1EtQ+esfYFRm3614Ty4uNtExkcbgd6nWsXphbqKyf9ZYdbZdv2xEoWEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.69.0",
+ "@typescript-eslint/visitor-keys": "8.69.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.69.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.69.0.tgz",
+ "integrity": "sha512-xNqK7YTDZsLniQMV/4rpFR8Z5JlqeRvVjuG1YgF/mdPVH84HSD19L8CczMA0qg2RfwEV231GHH3VnToJDo4MfQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.69.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.69.0.tgz",
+ "integrity": "sha512-ZfoJAVg3JZndQEpEl9petVlxau3lRuElc4HRMuAlLCf8to04/iHz692RUSNmXKDjEuJmIL+KZ2/BsOcBc16dsA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.69.0",
+ "@typescript-eslint/typescript-estree": "8.69.0",
+ "@typescript-eslint/utils": "8.69.0",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.69.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.69.0.tgz",
+ "integrity": "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.69.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.69.0.tgz",
+ "integrity": "sha512-AdFkgqck3Vudb/kWnxlyafU/4aBhHrbQ9locP2N4psXTy5mOBg0SHJumnLvx7r6g1gV4DKvUFwV2nJZBoqOD8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.69.0",
+ "@typescript-eslint/tsconfig-utils": "8.69.0",
+ "@typescript-eslint/types": "8.69.0",
+ "@typescript-eslint/visitor-keys": "8.69.0",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
+ "version": "5.0.9",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
+ "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
+ "version": "10.2.6",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
+ "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.8"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.69.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.69.0.tgz",
+ "integrity": "sha512-tUbx60BBqQa31kXF5MCsOOLL5E/WzUuxIn7YpAvq+eaUlqvk8/NXnXMBNAdLCr0icjkzem7iUA5QqWHe/hJ1aw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.69.0",
+ "@typescript-eslint/types": "8.69.0",
+ "@typescript-eslint/typescript-estree": "8.69.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.69.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.69.0.tgz",
+ "integrity": "sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.69.0",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.18.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
+ "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "dev": true,
+ "license": "Python-2.0"
+ },
+ "node_modules/balanced-match": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/bowser": {
+ "version": "2.14.1",
+ "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
+ "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
+ "license": "MIT"
+ },
+ "node_modules/brace-expansion": {
+ "version": "1.1.18",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
+ "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0",
+ "concat-map": "0.0.1"
+ }
+ },
+ "node_modules/callsites": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
+ "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/concat-map": {
+ "version": "0.0.1",
+ "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
+ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "9.39.5",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.5.tgz",
+ "integrity": "sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==",
+ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.1",
+ "@eslint/config-array": "^0.21.2",
+ "@eslint/config-helpers": "^0.4.2",
+ "@eslint/core": "^0.17.0",
+ "@eslint/eslintrc": "^3.3.6",
+ "@eslint/js": "9.39.5",
+ "@eslint/plugin-kit": "^0.4.1",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.14.0",
+ "chalk": "^4.0.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^8.4.0",
+ "eslint-visitor-keys": "^4.2.1",
+ "espree": "^10.4.0",
+ "esquery": "^1.5.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "lodash.merge": "^4.6.2",
+ "minimatch": "^3.1.5",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz",
+ "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "4.2.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
+ "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "10.4.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
+ "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.15.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^4.2.1"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.4.4",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
+ "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "14.0.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz",
+ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/js-yaml": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
+ "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/puzrin"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/nodeca"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lodash.merge": {
+ "version": "4.6.2",
+ "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
+ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/minimatch": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^1.1.7"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parent-module": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
+ "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "callsites": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.7",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+ "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.9.6",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz",
+ "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
+ "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+ "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "license": "0BSD"
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.69.0",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.69.0.tgz",
+ "integrity": "sha512-B3MltX0VqjUBNEe3b3sSuiRbfa6XrfHFtBiPamjT5AsW/dfq+y+bc0wyuS9DxAS1LyzCxRp2+rxzpLUvqM2BvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.69.0",
+ "@typescript-eslint/parser": "8.69.0",
+ "@typescript-eslint/typescript-estree": "8.69.0",
+ "@typescript-eslint/utils": "8.69.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ }
+ }
+}
diff --git a/node/package.json b/node/package.json
new file mode 100644
index 0000000..bb8cc01
--- /dev/null
+++ b/node/package.json
@@ -0,0 +1,67 @@
+{
+ "name": "@aws/developer-toolkit-elasticache",
+ "version": "1.0.0",
+ "description": "Developer toolkit for Amazon ElastiCache",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/aws/developer-toolkit-elasticache.git",
+ "directory": "node"
+ },
+ "homepage": "https://github.com/aws/developer-toolkit-elasticache/tree/main/node#readme",
+ "bugs": "https://github.com/aws/developer-toolkit-elasticache/issues",
+ "type": "module",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=22"
+ },
+ "supportedNodeVersions": [
+ "22.x",
+ "24.x"
+ ],
+ "files": [
+ "dist",
+ "CHANGELOG.md",
+ "LICENSE",
+ "NOTICE",
+ "README.md"
+ ],
+ "publishConfig": {
+ "access": "public"
+ },
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "scripts": {
+ "build": "tsc -p tsconfig.json",
+ "build:test": "tsc -p tsconfig.test.json",
+ "clean": "node scripts/clean.mjs",
+ "format": "prettier --write .",
+ "format:check": "prettier --check .",
+ "lint": "eslint .",
+ "list-supported-versions": "node scripts/list-supported-versions.mjs",
+ "verify": "node scripts/verify.mjs",
+ "package:check": "npm run clean && npm run build && npm pack --dry-run",
+ "test": "npm run build && npm run build:test && node --test dist-test/test/*.test.js",
+ "typecheck": "tsc -p tsconfig.json --noEmit"
+ },
+ "dependencies": {
+ "@aws-sdk/credential-providers": "^3.943.0",
+ "@smithy/core": "^3.18.0",
+ "@smithy/hash-node": "^4.2.7",
+ "@smithy/signature-v4": "^5.2.7",
+ "@smithy/types": "^4.9.0"
+ },
+ "devDependencies": {
+ "@eslint/js": "^9.39.1",
+ "@types/node": "^24.10.1",
+ "eslint": "^9.39.1",
+ "prettier": "^3.7.4",
+ "typescript": "^5.9.3",
+ "typescript-eslint": "^8.46.3"
+ }
+}
diff --git a/node/scripts/clean.mjs b/node/scripts/clean.mjs
new file mode 100644
index 0000000..0a3ef8e
--- /dev/null
+++ b/node/scripts/clean.mjs
@@ -0,0 +1,8 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { rmSync } from "node:fs";
+
+for (const directory of ["dist", "dist-test"]) {
+ rmSync(directory, { force: true, recursive: true });
+}
diff --git a/node/scripts/list-supported-versions.mjs b/node/scripts/list-supported-versions.mjs
new file mode 100644
index 0000000..9e524f1
--- /dev/null
+++ b/node/scripts/list-supported-versions.mjs
@@ -0,0 +1,50 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { readFileSync } from "node:fs";
+import { stdout } from "node:process";
+import { URL } from "node:url";
+
+const packageJson = JSON.parse(
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
+);
+const versions = packageJson.supportedNodeVersions;
+
+if (!Array.isArray(versions) || versions.length === 0) {
+ throw new Error("package.json supportedNodeVersions must be a non-empty array.");
+}
+
+const majors = versions.map((version) => {
+ const match = /^(\d+)\.x$/.exec(version);
+ if (!match) {
+ throw new Error(
+ `Unsupported Node.js version ${JSON.stringify(version)}: expected ".x".`,
+ );
+ }
+ return Number(match[1]);
+});
+
+if (new Set(majors).size !== majors.length) {
+ throw new Error("package.json supportedNodeVersions must not contain duplicates.");
+}
+
+if (majors.some((major, index) => index > 0 && major <= majors[index - 1])) {
+ throw new Error(
+ "package.json supportedNodeVersions must be ordered from oldest to newest.",
+ );
+}
+
+const engineMatch = /^>=(\d+)$/.exec(packageJson.engines?.node ?? "");
+if (!engineMatch || Number(engineMatch[1]) !== majors[0]) {
+ throw new Error(
+ "package.json engines.node must match the oldest supported Node.js version.",
+ );
+}
+
+stdout.write(
+ JSON.stringify({
+ all: versions,
+ min: versions[0],
+ max: versions.at(-1),
+ }),
+);
diff --git a/node/scripts/verify.mjs b/node/scripts/verify.mjs
new file mode 100644
index 0000000..f63d3b2
--- /dev/null
+++ b/node/scripts/verify.mjs
@@ -0,0 +1,118 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+// Release gate, run by node-release.yml before anything is built or published:
+//
+// node scripts/verify.mjs [TAG] [--require-npm ]
+//
+// Checks that the release tag matches package.json's version, that the package is no
+// longer marked private (the publish guard), that the version is not a prerelease, that
+// CHANGELOG.md has an entry for the version, and that package.json declares the
+// repository.
+// With --require-npm, also checks the local npm is at least that version, which the
+// publish job needs for trusted publishing. Prints a GitHub Actions error annotation
+// and exits non-zero on the first failure.
+
+import { execFileSync } from "node:child_process";
+import { readFileSync } from "node:fs";
+import { argv, env, exit, platform, stdout } from "node:process";
+import { URL } from "node:url";
+
+const TAG_PREFIX = "node-v";
+const EXPECTED_REPOSITORY = "https://github.com/aws/developer-toolkit-elasticache";
+
+function fail(message) {
+ stdout.write(`::error::${message}\n`);
+ exit(1);
+}
+
+function versionParts(value, what) {
+ const parts = value.split(".").map(Number);
+ if (parts.some((part) => !Number.isInteger(part) || part < 0)) {
+ fail(`${what} is not a dotted numeric version: ${value}`);
+ }
+ return parts;
+}
+
+/** True when dotted version `a` is older than `b`. */
+function olderThan(a, b) {
+ const [left, right] = [
+ versionParts(a, "the installed npm version"),
+ versionParts(b, "--require-npm"),
+ ];
+ for (let i = 0; i < Math.max(left.length, right.length); i++) {
+ const [l, r] = [left[i] ?? 0, right[i] ?? 0];
+ if (l !== r) {
+ return l < r;
+ }
+ }
+ return false;
+}
+
+const args = argv.slice(2);
+const flagIndex = args.indexOf("--require-npm");
+const requiredNpm = flagIndex === -1 ? undefined : args[flagIndex + 1];
+if (flagIndex !== -1 && (requiredNpm === undefined || requiredNpm.startsWith("-"))) {
+ fail("--require-npm needs a version, for example --require-npm 11.5.1.");
+}
+const positional =
+ flagIndex === -1 ? args : args.filter((_, i) => i !== flagIndex && i !== flagIndex + 1);
+const tag = positional[0] ?? env.GITHUB_REF_NAME ?? "";
+
+const packageJson = JSON.parse(
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
+);
+const version = packageJson.version;
+
+if (tag !== `${TAG_PREFIX}${version}`) {
+ fail(
+ `Release tag does not match the version in package.json (${version}). ` +
+ `Expected ${TAG_PREFIX}${version}. Bump the version and re-tag.`,
+ );
+}
+
+if (packageJson.private === true) {
+ fail(
+ 'package.json still has "private": true. Remove it in the release commit once ' +
+ "the open-source review has cleared.",
+ );
+}
+
+if (version.includes("-")) {
+ fail(
+ `Version ${version} is a prerelease. Only release versions (X.Y.Z) are published.`,
+ );
+}
+
+const repositoryUrl =
+ typeof packageJson.repository === "string"
+ ? packageJson.repository
+ : packageJson.repository?.url;
+if (!repositoryUrl || !repositoryUrl.includes(EXPECTED_REPOSITORY)) {
+ fail(
+ `package.json repository.url must point at ${EXPECTED_REPOSITORY}; ` +
+ "npm provenance verifies it against the publishing repository.",
+ );
+}
+
+const changelog = readFileSync(new URL("../CHANGELOG.md", import.meta.url), "utf8");
+if (!changelog.includes(`## [${version}]`)) {
+ fail(`CHANGELOG.md has no "## [${version}]" entry. Add one in the release commit.`);
+}
+
+if (requiredNpm !== undefined) {
+ // Windows: npm is a .cmd shim, which Node only spawns through a shell.
+ const npm = platform === "win32" ? "npm.cmd" : "npm";
+ const installed = execFileSync(npm, ["--version"], {
+ encoding: "utf8",
+ shell: platform === "win32",
+ }).trim();
+ if (olderThan(installed, requiredNpm)) {
+ fail(
+ `npm ${installed} is older than ${requiredNpm}, the minimum for trusted ` +
+ "publishing. Use a Node.js release that bundles a newer npm.",
+ );
+ }
+}
+
+stdout.write(`Publishing version ${version}.\n`);
diff --git a/node/src/errors.ts b/node/src/errors.ts
new file mode 100644
index 0000000..da50e61
--- /dev/null
+++ b/node/src/errors.ts
@@ -0,0 +1,45 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+/**
+ * Base class for failures that can be corrected by changing the invocation or
+ * the AWS configuration.
+ */
+export class ToolkitInputError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "ToolkitInputError";
+ }
+}
+
+/**
+ * A parameter value or combination of values is invalid.
+ */
+export class InvalidParameterError extends ToolkitInputError {
+ constructor(message: string) {
+ super(message);
+ this.name = "InvalidParameterError";
+ }
+}
+
+/**
+ * Required AWS configuration, such as a region or credentials, is missing.
+ */
+export class ConfigurationError extends ToolkitInputError {
+ constructor(message: string) {
+ super(message);
+ this.name = "ConfigurationError";
+ }
+}
+
+/**
+ * A cached token could not be refreshed before it expired, or the token manager
+ * was closed. This is an operational failure rather than an input error, so it
+ * does not extend {@link ToolkitInputError}.
+ */
+export class TokenRefreshError extends Error {
+ constructor(message: string, options?: ErrorOptions) {
+ super(message, options);
+ this.name = "TokenRefreshError";
+ }
+}
diff --git a/node/src/index.ts b/node/src/index.ts
new file mode 100644
index 0000000..bc723ac
--- /dev/null
+++ b/node/src/index.ts
@@ -0,0 +1,24 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+export {
+ ConfigurationError,
+ InvalidParameterError,
+ TokenRefreshError,
+ ToolkitInputError,
+} from "./errors.js";
+export type {
+ TokenGeneratorDependencies,
+ TokenGeneratorOptions,
+} from "./token-generator.js";
+export {
+ ElastiCacheIAMAuthTokenProvider,
+ generateIamAuthToken,
+} from "./token-generator.js";
+export type {
+ TokenManagerDependencies,
+ TokenManagerOptions,
+ TokenManagerScheduler,
+ TokenManagerTimer,
+} from "./token-manager.js";
+export { ElastiCacheIAMAuthTokenManager } from "./token-manager.js";
diff --git a/node/src/token-generator.ts b/node/src/token-generator.ts
new file mode 100644
index 0000000..1637382
--- /dev/null
+++ b/node/src/token-generator.ts
@@ -0,0 +1,386 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { fromNodeProviderChain } from "@aws-sdk/credential-providers";
+import { loadConfig, NODE_REGION_CONFIG_OPTIONS } from "@smithy/core/config";
+import { HttpRequest } from "@smithy/core/protocols";
+import { Hash } from "@smithy/hash-node";
+import { SignatureV4 } from "@smithy/signature-v4";
+import type {
+ AwsCredentialIdentity,
+ AwsCredentialIdentityProvider,
+ QueryParameterBag,
+} from "@smithy/types";
+
+import { ConfigurationError, InvalidParameterError } from "./errors.js";
+
+/** Lifetime of a presigned ElastiCache IAM token, in seconds. */
+export const TOKEN_TTL_SECONDS = 900;
+const SIGNING_SERVICE = "elasticache";
+
+// The cache name becomes the SigV4 signing host. Keep this validation tight so a
+// malformed or hostile value cannot be signed as a different host.
+const CACHE_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9]*(-[a-zA-Z0-9]+)*$/;
+
+// ElastiCache service-managed user ids may use the special "default." prefix,
+// for example "default.iam-user".
+const USER_ID_PATTERN = /^(?:default\.)?[a-zA-Z][a-zA-Z0-9-]*$/;
+
+const NO_REGION_MESSAGE =
+ "No AWS region found. Pass region explicitly, or configure one via " +
+ "AWS_REGION, AWS_DEFAULT_REGION, or the region setting in your AWS config profile.";
+const NO_CREDENTIALS_MESSAGE =
+ "No AWS credentials found. Configure credentials via the environment, " +
+ "shared config/credentials files, or an instance/container role.";
+
+export interface TokenGeneratorOptions {
+ /** ElastiCache user id used in the signed `User` query parameter. */
+ userId: string;
+ /** AWS region. Resolution falls back to the supported AWS configuration sources. */
+ region?: string;
+ /** Name of a serverless cache. Exactly one target option is required. */
+ serverlessCacheName?: string;
+ /** Id of a node-based replication group. Exactly one target option is required. */
+ replicationGroupId?: string;
+}
+
+/**
+ * Advanced dependencies for deterministic tests and applications with an
+ * explicitly managed AWS configuration. These are intentionally optional; the
+ * normal API uses the standard Node.js AWS credential and region providers.
+ */
+export interface TokenGeneratorDependencies {
+ credentialProvider?: AwsCredentialIdentityProvider;
+ regionProvider?: () => Promise;
+ signingDate?: Date;
+}
+
+type Target = {
+ cacheName: string;
+ serverless: boolean;
+};
+
+type RegionResolution = { region: string } | { error: ConfigurationError };
+
+function validateAndNormalize(
+ parameter: string,
+ value: unknown,
+ pattern: RegExp,
+): string {
+ if (typeof value !== "string") {
+ throw new InvalidParameterError(
+ `Invalid value for parameter '${parameter}': must match ${pattern.source}`,
+ );
+ }
+
+ // ElastiCache stores cache names and user ids lowercase server-side. Normalize
+ // before validation and signing so the signed value matches the value sent by
+ // the client; otherwise authentication can fail with WRONGPASS.
+ const normalized = value.toLowerCase();
+ if (!pattern.test(normalized)) {
+ // Do not include the supplied value: it may contain newlines or URL syntax.
+ throw new InvalidParameterError(
+ `Invalid value for parameter '${parameter}': must match ${pattern.source}`,
+ );
+ }
+ return normalized;
+}
+
+function resolveTarget(options: TokenGeneratorOptions): Target {
+ const { serverlessCacheName, replicationGroupId } = options;
+
+ // Validate inside each branch so an invalid value is reported under the public
+ // parameter name the caller actually supplied.
+ if (serverlessCacheName && !replicationGroupId) {
+ return {
+ cacheName: validateAndNormalize(
+ "serverlessCacheName",
+ serverlessCacheName,
+ CACHE_NAME_PATTERN,
+ ),
+ serverless: true,
+ };
+ }
+
+ if (replicationGroupId && !serverlessCacheName) {
+ return {
+ cacheName: validateAndNormalize(
+ "replicationGroupId",
+ replicationGroupId,
+ CACHE_NAME_PATTERN,
+ ),
+ serverless: false,
+ };
+ }
+
+ throw new InvalidParameterError(
+ "Invalid parameter combination for 'serverlessCacheName' and " +
+ "'replicationGroupId': exactly one must be provided.",
+ );
+}
+
+async function resolveRegion(
+ region: string | undefined,
+ regionProvider: (() => Promise) | undefined,
+): Promise {
+ const fromArgument = region || undefined;
+ const fromEnvironment = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION;
+ if (fromArgument || fromEnvironment) {
+ return fromArgument || fromEnvironment!;
+ }
+
+ // Environment variables were checked above; now fall back to the shared
+ // AWS configuration while preserving the documented precedence.
+ try {
+ const resolved = await (regionProvider ?? loadSharedRegion)();
+ if (resolved) {
+ return resolved;
+ }
+ } catch {
+ // Present one stable, actionable configuration error instead of leaking
+ // provider internals or a file path from the local AWS configuration.
+ }
+
+ throw new ConfigurationError(NO_REGION_MESSAGE);
+}
+
+function retainRegion(
+ region: string | undefined,
+ regionProvider: (() => Promise) | undefined,
+): Promise {
+ // Keep this promise fulfilled even when configuration is invalid. A provider
+ // may be constructed without an immediate async error observer; getToken()
+ // reports the retained configuration error when the token is requested.
+ return resolveRegion(region, regionProvider).then(
+ (resolvedRegion) => ({ region: resolvedRegion }),
+ (error: unknown) => ({
+ error:
+ error instanceof ConfigurationError
+ ? error
+ : new ConfigurationError(NO_REGION_MESSAGE),
+ }),
+ );
+}
+
+async function loadSharedRegion(): Promise {
+ // Override the SDK selector/default so all "no region" cases return undefined
+ // here and are converted by resolveRegion into one stable ConfigurationError,
+ // rather than exposing the SDK's provider-specific error shape.
+ const regionConfig = {
+ ...NODE_REGION_CONFIG_OPTIONS,
+ environmentVariableSelector: (env: Record) =>
+ env.AWS_REGION || env.AWS_DEFAULT_REGION,
+ default: async () => undefined,
+ };
+ return loadConfig(regionConfig)();
+}
+
+async function resolveCredentials(
+ credentialProvider: AwsCredentialIdentityProvider,
+): Promise {
+ try {
+ // Resolve on every request instead of caching: SSO, assume-role, container,
+ // and instance-metadata providers may rotate or refresh their credentials.
+ const credentials = await credentialProvider();
+ if (!credentials?.accessKeyId || !credentials.secretAccessKey) {
+ throw new Error("The credential provider returned no usable credentials.");
+ }
+ return credentials;
+ } catch {
+ throw new ConfigurationError(NO_CREDENTIALS_MESSAGE);
+ }
+}
+
+function encodeQueryComponent(value: string): string {
+ // encodeURIComponent leaves !'()* unescaped, but SigV4 requires them to be
+ // percent-encoded. Returning a different encoding from the one that was signed
+ // makes the service canonicalize a different query and silently reject the token.
+ return encodeURIComponent(value).replace(
+ /[!'()*]/g,
+ (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
+ );
+}
+
+function formatQuery(query: QueryParameterBag): string {
+ // Preserve repeated query values while applying the SigV4-compatible encoding
+ // above to every key and value in the final token.
+ const entries: string[] = [];
+ for (const [key, value] of Object.entries(query)) {
+ if (value === null) {
+ entries.push(`${encodeQueryComponent(key)}=`);
+ continue;
+ }
+ const values = Array.isArray(value) ? value : [value];
+ for (const item of values) {
+ entries.push(`${encodeQueryComponent(key)}=${encodeQueryComponent(item)}`);
+ }
+ }
+ return entries.join("&");
+}
+
+/**
+ * Sign the ElastiCache IAM authentication request.
+ *
+ * `target.cacheName` is deliberately used as the hostname and `Host` header: it
+ * is the SigV4 signing host expected by ElastiCache, not the connection endpoint
+ * DNS name. For serverless caches, `ResourceType` is part of the canonical query,
+ * so it must be added before presigning rather than appended afterward.
+ */
+async function signToken(
+ target: Target,
+ userId: string,
+ region: string,
+ credentials: AwsCredentialIdentity,
+ signingDate: Date | undefined,
+): Promise {
+ const query: QueryParameterBag = {
+ Action: "connect",
+ User: userId,
+ };
+ if (target.serverless) {
+ // This parameter changes the canonical request and therefore the signature;
+ // node-based replication groups must omit it.
+ query.ResourceType = "ServerlessCache";
+ }
+
+ const request = new HttpRequest({
+ protocol: "https:",
+ hostname: target.cacheName,
+ method: "GET",
+ path: "/",
+ headers: { host: target.cacheName },
+ query,
+ });
+ const signer = new SignatureV4({
+ credentials,
+ region,
+ service: SIGNING_SERVICE,
+ sha256: Hash.bind(null, "sha256"),
+ });
+ const signed = await signer.presign(request, {
+ expiresIn: TOKEN_TTL_SECONDS,
+ signingDate,
+ });
+
+ return `${signed.hostname}/?${formatQuery(signed.query ?? {})}`;
+}
+
+function defaultCredentialProvider(): AwsCredentialIdentityProvider {
+ return fromNodeProviderChain();
+}
+
+/**
+ * Generate an ElastiCache IAM authentication token.
+ *
+ * Exactly one of `serverlessCacheName` or `replicationGroupId` must be provided.
+ * The selected value is used as the SigV4 signing host; it is not the connection
+ * endpoint DNS name. The `userId` is normalized to the lowercase value used in the
+ * signed `User` query parameter.
+ *
+ * When `region` is omitted, resolution checks `AWS_REGION`, `AWS_DEFAULT_REGION`,
+ * and the standard AWS shared configuration. Credentials use the standard Node.js
+ * provider chain and are resolved for each invocation so refreshed credentials are
+ * picked up.
+ *
+ * @param options Target, user, and optional region used to create the token.
+ * @param dependencies Optional providers and signing time for controlled callers
+ * and deterministic tests.
+ * @returns The presigned ElastiCache IAM authentication token.
+ */
+export async function generateIamAuthToken(
+ options: TokenGeneratorOptions,
+ dependencies: TokenGeneratorDependencies = {},
+): Promise {
+ const target = resolveTarget(options);
+ const userId = validateAndNormalize("userId", options.userId, USER_ID_PATTERN);
+ const region = await resolveRegion(options.region, dependencies.regionProvider);
+ const credentials = await resolveCredentials(
+ dependencies.credentialProvider ?? defaultCredentialProvider(),
+ );
+
+ return signToken(target, userId, region, credentials, dependencies.signingDate);
+}
+
+/**
+ * Client-agnostic ElastiCache IAM token provider.
+ *
+ * The target and normalized user id are retained, and the region is resolved once
+ * during construction. `getToken()` signs a fresh token and re-fetches credentials
+ * on every call so rotated SSO, assume-role, container, or instance credentials are
+ * used without changing the signing region.
+ *
+ * Region resolution is asynchronous, so a constructor cannot report a missing region.
+ * Prefer `ElastiCacheIAMAuthTokenProvider.create()`, which awaits that resolution and
+ * fails immediately; the constructor retains the error and reports it from the first
+ * `getToken()` call instead.
+ */
+export class ElastiCacheIAMAuthTokenProvider {
+ private readonly target: Target;
+ private readonly userIdValue: string;
+ private readonly credentialProvider: AwsCredentialIdentityProvider;
+ private readonly regionResolution: Promise;
+ private readonly signingDate?: Date;
+
+ constructor(
+ options: TokenGeneratorOptions,
+ dependencies: TokenGeneratorDependencies = {},
+ ) {
+ this.target = resolveTarget(options);
+ this.userIdValue = validateAndNormalize("userId", options.userId, USER_ID_PATTERN);
+ this.credentialProvider =
+ dependencies.credentialProvider ?? defaultCredentialProvider();
+ this.regionResolution = retainRegion(options.region, dependencies.regionProvider);
+ this.signingDate = dependencies.signingDate;
+ }
+
+ /**
+ * Create a provider, resolving the region before returning.
+ *
+ * Use this instead of `new` to surface a missing or unreadable region
+ * configuration up front rather than at the first connection attempt.
+ *
+ * @throws {InvalidParameterError} for an invalid target or user id.
+ * @throws {ConfigurationError} when no region can be resolved.
+ */
+ static async create(
+ options: TokenGeneratorOptions,
+ dependencies: TokenGeneratorDependencies = {},
+ ): Promise {
+ const provider = new ElastiCacheIAMAuthTokenProvider(options, dependencies);
+ await provider.ensureRegionResolved();
+ return provider;
+ }
+
+ /**
+ * Await region resolution, throwing the retained configuration error if the
+ * region could not be resolved.
+ *
+ * @throws {ConfigurationError} when no region can be resolved.
+ */
+ async ensureRegionResolved(): Promise {
+ const regionResolution = await this.regionResolution;
+ if ("error" in regionResolution) {
+ throw regionResolution.error;
+ }
+ }
+
+ get userId(): string {
+ return this.userIdValue;
+ }
+
+ /** Sign and return a fresh token using the provider's retained target and region. */
+ async getToken(): Promise {
+ const regionResolution = await this.regionResolution;
+ if ("error" in regionResolution) {
+ throw regionResolution.error;
+ }
+ const credentials = await resolveCredentials(this.credentialProvider);
+ return signToken(
+ this.target,
+ this.userIdValue,
+ regionResolution.region,
+ credentials,
+ this.signingDate,
+ );
+ }
+}
diff --git a/node/src/token-manager.ts b/node/src/token-manager.ts
new file mode 100644
index 0000000..fd422d8
--- /dev/null
+++ b/node/src/token-manager.ts
@@ -0,0 +1,361 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import { InvalidParameterError, TokenRefreshError } from "./errors.js";
+import { ElastiCacheIAMAuthTokenProvider, TOKEN_TTL_SECONDS } from "./token-generator.js";
+import type {
+ TokenGeneratorDependencies,
+ TokenGeneratorOptions,
+} from "./token-generator.js";
+
+const DEFAULT_REFRESH_AFTER_SECONDS = 300;
+const MAX_ATTEMPTS = 8;
+const BASE_DELAY_MS = 100;
+const MAX_DELAY_MS = 5_000;
+const JITTER_RATIO = 0.2;
+
+// ElastiCache tokens are valid for TOKEN_TTL_SECONDS from issuance, but clock
+// skew and in-flight requests mean a token accepted at signing time can be
+// rejected by the time it reaches the server. Serve tokens for a shorter,
+// effective lifetime so a cached token is never handed out right at the edge
+// of the real expiry.
+const SERVE_MARGIN_SECONDS = 20;
+const EFFECTIVE_TOKEN_LIFETIME_SECONDS = TOKEN_TTL_SECONDS - SERVE_MARGIN_SECONDS;
+
+const CLOSED_MESSAGE = "The token manager is closed.";
+const REFRESH_FAILED_MESSAGE =
+ "Could not refresh the ElastiCache IAM authentication token before the " +
+ "previous token expired.";
+
+/** A cancellable timer that can be detached from the event loop. */
+export interface TokenManagerTimer {
+ unref(): void;
+}
+
+/** Timer source, injectable so tests can advance time deterministically. */
+export interface TokenManagerScheduler {
+ setTimer(callback: () => void, delayMs: number): TokenManagerTimer;
+ clearTimer(timer: TokenManagerTimer): void;
+}
+
+export interface TokenManagerOptions extends TokenGeneratorOptions {
+ /**
+ * Seconds after issuance at which a token is refreshed in the background.
+ * Must be greater than 0 and less than the effective token lifetime (the
+ * 900 second token lifetime minus a 20 second serve margin, i.e. 880).
+ * Defaults to 300.
+ */
+ refreshAfterSeconds?: number;
+ /**
+ * Called with each newly installed token. Synchronous throws and rejected
+ * callback promises are ignored: they must not discard a token that was
+ * minted successfully.
+ */
+ onTokenChanged?: (token: string) => void;
+}
+
+/**
+ * Advanced dependencies for deterministic tests and applications with an
+ * explicitly managed AWS configuration.
+ */
+export interface TokenManagerDependencies extends TokenGeneratorDependencies {
+ /** Monotonic-enough clock in milliseconds. Defaults to `Date.now`. */
+ now?: () => number;
+ /** Jitter source in [0, 1). Defaults to `Math.random`. */
+ random?: () => number;
+ scheduler?: TokenManagerScheduler;
+}
+
+type CachedToken = {
+ token: string;
+ issuedAt: number;
+ refreshAt: number;
+ expiresAt: number;
+};
+
+const defaultScheduler: TokenManagerScheduler = {
+ setTimer(callback, delayMs) {
+ return setTimeout(callback, delayMs);
+ },
+ clearTimer(timer) {
+ clearTimeout(timer as ReturnType);
+ },
+};
+
+function resolveRefreshAfterSeconds(value: unknown): number {
+ const resolved = value ?? DEFAULT_REFRESH_AFTER_SECONDS;
+ if (
+ typeof resolved !== "number" ||
+ !Number.isFinite(resolved) ||
+ resolved <= 0 ||
+ resolved >= EFFECTIVE_TOKEN_LIFETIME_SECONDS
+ ) {
+ throw new InvalidParameterError(
+ "Invalid value for parameter 'refreshAfterSeconds': must be greater than 0 " +
+ `and less than ${EFFECTIVE_TOKEN_LIFETIME_SECONDS}.`,
+ );
+ }
+ return resolved;
+}
+
+/**
+ * Caches an ElastiCache IAM authentication token and refreshes it before expiry.
+ *
+ * The first token is minted lazily. Subsequent calls reuse it while valid, and
+ * concurrent callers share one refresh. Call `close()` when the manager is no
+ * longer needed.
+ */
+export class ElastiCacheIAMAuthTokenManager {
+ private readonly provider: ElastiCacheIAMAuthTokenProvider;
+ private readonly refreshAfterMs: number;
+ private readonly onTokenChanged?: (token: string) => void;
+ private readonly now: () => number;
+ private readonly random: () => number;
+ private readonly scheduler: TokenManagerScheduler;
+
+ private cached?: CachedToken;
+ private inFlight?: Promise;
+ private refreshTimer?: TokenManagerTimer;
+ private retryTimer?: TokenManagerTimer;
+ private cancelSleep?: () => void;
+ private closed = false;
+ /**
+ * When set, `getToken()` serves the cached token without starting another
+ * refresh cycle until this time: a refresh cycle is already scheduled to
+ * retry, and starting a second one would duplicate that work. Cleared by a
+ * successful refresh.
+ */
+ private retryNotBefore?: number;
+
+ constructor(options: TokenManagerOptions, dependencies: TokenManagerDependencies = {}) {
+ this.provider = new ElastiCacheIAMAuthTokenProvider(options, dependencies);
+ this.refreshAfterMs = resolveRefreshAfterSeconds(options.refreshAfterSeconds) * 1000;
+ this.onTokenChanged = options.onTokenChanged;
+ this.now = dependencies.now ?? Date.now;
+ this.random = dependencies.random ?? Math.random;
+ this.scheduler = dependencies.scheduler ?? defaultScheduler;
+ }
+
+ /**
+ * Create a manager, resolving the region before returning.
+ *
+ * @throws {InvalidParameterError} for an invalid target, user id, or refresh interval.
+ * @throws {ConfigurationError} when no region can be resolved.
+ */
+ static async create(
+ options: TokenManagerOptions,
+ dependencies: TokenManagerDependencies = {},
+ ): Promise {
+ const manager = new ElastiCacheIAMAuthTokenManager(options, dependencies);
+ await manager.provider.ensureRegionResolved();
+ return manager;
+ }
+
+ get userId(): string {
+ return this.provider.userId;
+ }
+
+ /**
+ * Return the normalized user id and a valid token for credential-provider
+ * integrations.
+ */
+ async getCredentials(): Promise<[userId: string, token: string]> {
+ return [this.userId, await this.getToken()];
+ }
+
+ /**
+ * Return a valid token, minting one on the first call.
+ *
+ * While a cached token remains valid it is returned immediately. Once its
+ * refresh point is reached, refresh proceeds in the background.
+ *
+ * @throws {TokenRefreshError} when a refresh could not replace a token that has
+ * since expired, or when the manager is closed.
+ */
+ async getToken(): Promise {
+ if (this.closed) {
+ throw new TokenRefreshError(CLOSED_MESSAGE);
+ }
+
+ const cached = this.cached;
+ if (cached && this.now() < cached.expiresAt) {
+ if (this.now() >= cached.refreshAt && !this.inRetryCooldown()) {
+ this.startBackgroundRefresh();
+ }
+ return cached.token;
+ }
+ return this.refresh();
+ }
+
+ /**
+ * Force a new token, invalidating the cached one instead of waiting for the
+ * next scheduled background refresh.
+ *
+ * A refresh already in progress, whether started here or in the background,
+ * is shared: concurrent callers wait for the same replacement token.
+ *
+ * @throws {TokenRefreshError} when the manager is closed.
+ * @throws {ConfigurationError} when no usable AWS credentials can be resolved.
+ */
+ async refreshToken(): Promise {
+ if (this.closed) {
+ throw new TokenRefreshError(CLOSED_MESSAGE);
+ }
+ this.cached = undefined;
+ this.clearRefreshTimer();
+ this.retryNotBefore = undefined;
+ return this.refresh();
+ }
+
+ /**
+ * Stop all background work and release the manager's timers.
+ *
+ * Subsequent `getToken()` calls reject; a refresh already in flight stops at
+ * its next attempt.
+ */
+ close(): void {
+ this.closed = true;
+ this.clearRefreshTimer();
+ this.clearRetryTimer();
+ const cancelSleep = this.cancelSleep;
+ this.cancelSleep = undefined;
+ cancelSleep?.();
+ }
+
+ private refresh(): Promise {
+ if (!this.inFlight) {
+ const cycle = this.runRefreshCycle();
+ this.inFlight = cycle;
+ const clear = () => {
+ if (this.inFlight === cycle) {
+ this.inFlight = undefined;
+ }
+ };
+ cycle.then(clear, clear); // clear no matter the outcome
+ }
+ return this.inFlight;
+ }
+
+ private startBackgroundRefresh(): void {
+ void this.refresh().catch(() => {});
+ }
+
+ private async runRefreshCycle(): Promise {
+ let lastError: unknown = new TokenRefreshError(CLOSED_MESSAGE);
+
+ for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
+ if (this.closed) {
+ throw new TokenRefreshError(CLOSED_MESSAGE, { cause: lastError });
+ }
+ try {
+ const issuedAt = this.now();
+ const token = await this.provider.getToken();
+ if (this.closed) {
+ throw new TokenRefreshError(CLOSED_MESSAGE);
+ }
+ this.install(token, issuedAt);
+ return token;
+ } catch (error) {
+ lastError = error;
+ if (this.closed) {
+ throw new TokenRefreshError(CLOSED_MESSAGE, { cause: error });
+ }
+ if (attempt === MAX_ATTEMPTS) {
+ break;
+ }
+ await this.sleep(this.backoffDelay(attempt));
+ }
+ }
+
+ const cached = this.cached;
+
+ // If a cached token is still valid, return it and schedule another refresh. If
+ // there is no cached token, throw the last error. If there is a cached token
+ // but it has expired, throw a TokenRefreshError with the last error as its
+ // cause.
+ if (cached && this.now() < cached.expiresAt) {
+ this.retryNotBefore = this.now() + MAX_DELAY_MS;
+ this.scheduleRefresh(MAX_DELAY_MS);
+ return cached.token;
+ }
+ if (!cached) {
+ throw lastError;
+ }
+ throw new TokenRefreshError(REFRESH_FAILED_MESSAGE, { cause: lastError });
+ }
+
+ private install(token: string, issuedAt: number): void {
+ this.cached = {
+ token,
+ issuedAt,
+ refreshAt: issuedAt + this.refreshAfterMs,
+ expiresAt: issuedAt + EFFECTIVE_TOKEN_LIFETIME_SECONDS * 1000,
+ };
+ this.retryNotBefore = undefined;
+ this.scheduleRefresh(this.refreshAfterMs);
+ this.notify(token);
+ }
+
+ private inRetryCooldown(): boolean {
+ return this.retryNotBefore !== undefined && this.now() < this.retryNotBefore;
+ }
+
+ private notify(token: string): void {
+ if (!this.onTokenChanged) {
+ return;
+ }
+ try {
+ void Promise.resolve(this.onTokenChanged(token)).catch(() => {});
+ } catch {
+ // A consumer's callback failure must not invalidate a token that was
+ // minted successfully, and must not trigger another mint.
+ }
+ }
+
+ private scheduleRefresh(delayMs: number): void {
+ this.clearRefreshTimer();
+ if (this.closed) {
+ return;
+ }
+ const timer = this.scheduler.setTimer(() => {
+ this.refreshTimer = undefined;
+ this.startBackgroundRefresh();
+ }, delayMs);
+ timer.unref();
+ this.refreshTimer = timer;
+ }
+
+ private sleep(delayMs: number): Promise {
+ return new Promise((resolve) => {
+ const finish = () => {
+ this.retryTimer = undefined;
+ this.cancelSleep = undefined;
+ resolve();
+ };
+ this.cancelSleep = finish;
+ const timer = this.scheduler.setTimer(finish, delayMs);
+ timer.unref();
+ this.retryTimer = timer;
+ });
+ }
+
+ private backoffDelay(failedAttempt: number): number {
+ const delay = Math.min(BASE_DELAY_MS * 2 ** (failedAttempt - 1), MAX_DELAY_MS);
+ const jitter = 1 - JITTER_RATIO + 2 * JITTER_RATIO * this.random();
+ return Math.round(delay * jitter);
+ }
+
+ private clearRefreshTimer(): void {
+ if (this.refreshTimer) {
+ this.scheduler.clearTimer(this.refreshTimer);
+ this.refreshTimer = undefined;
+ }
+ }
+
+ private clearRetryTimer(): void {
+ if (this.retryTimer) {
+ this.scheduler.clearTimer(this.retryTimer);
+ this.retryTimer = undefined;
+ }
+ }
+}
diff --git a/node/test/list-supported-versions.test.ts b/node/test/list-supported-versions.test.ts
new file mode 100644
index 0000000..5fd8f11
--- /dev/null
+++ b/node/test/list-supported-versions.test.ts
@@ -0,0 +1,109 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { test } from "node:test";
+
+const PACKAGE_ROOT = resolve(fileURLToPath(new URL("../..", import.meta.url)));
+const SCRIPT_PATH = join(PACKAGE_ROOT, "scripts", "list-supported-versions.mjs");
+
+function runListSupportedVersions(packageJson: Record) {
+ const temporaryRoot = mkdtempSync(join(tmpdir(), "elasticache-node-versions-"));
+ const scriptsDirectory = join(temporaryRoot, "scripts");
+ const temporaryScript = join(scriptsDirectory, "list-supported-versions.mjs");
+
+ try {
+ mkdirSync(scriptsDirectory);
+ copyFileSync(SCRIPT_PATH, temporaryScript);
+ writeFileSync(join(temporaryRoot, "package.json"), JSON.stringify(packageJson));
+ return spawnSync(process.execPath, [temporaryScript], {
+ encoding: "utf8",
+ });
+ } finally {
+ rmSync(temporaryRoot, { recursive: true, force: true });
+ }
+}
+
+test("prints the supported Node.js version matrix", () => {
+ const result = runListSupportedVersions({
+ engines: { node: ">=22" },
+ supportedNodeVersions: ["22.x", "24.x"],
+ });
+
+ assert.equal(result.status, 0, result.stderr);
+ assert.deepEqual(JSON.parse(result.stdout), {
+ all: ["22.x", "24.x"],
+ min: "22.x",
+ max: "24.x",
+ });
+});
+
+const invalidMetadataCases = [
+ {
+ name: "rejects a missing supported version list",
+ packageJson: { engines: { node: ">=22" } },
+ expectedError: /must be a non-empty array/,
+ },
+ {
+ name: "rejects an empty supported version list",
+ packageJson: {
+ engines: { node: ">=22" },
+ supportedNodeVersions: [],
+ },
+ expectedError: /must be a non-empty array/,
+ },
+ {
+ name: "rejects a non-array supported version list",
+ packageJson: {
+ engines: { node: ">=22" },
+ supportedNodeVersions: "22.x",
+ },
+ expectedError: /must be a non-empty array/,
+ },
+ {
+ name: "rejects malformed supported versions",
+ packageJson: {
+ engines: { node: ">=22" },
+ supportedNodeVersions: ["22"],
+ },
+ expectedError: /expected "\.x"/,
+ },
+ {
+ name: "rejects duplicate supported versions",
+ packageJson: {
+ engines: { node: ">=22" },
+ supportedNodeVersions: ["22.x", "22.x"],
+ },
+ expectedError: /must not contain duplicates/,
+ },
+ {
+ name: "rejects unordered supported versions",
+ packageJson: {
+ engines: { node: ">=22" },
+ supportedNodeVersions: ["24.x", "22.x"],
+ },
+ expectedError: /must be ordered from oldest to newest/,
+ },
+ {
+ name: "rejects an engine that differs from the oldest supported version",
+ packageJson: {
+ engines: { node: ">=20" },
+ supportedNodeVersions: ["22.x", "24.x"],
+ },
+ expectedError: /must match the oldest supported Node\.js version/,
+ },
+] as const;
+
+for (const { name, packageJson, expectedError } of invalidMetadataCases) {
+ test(name, () => {
+ const result = runListSupportedVersions(packageJson);
+
+ assert.notEqual(result.status, 0);
+ assert.match(result.stderr, expectedError);
+ });
+}
diff --git a/node/test/package.test.ts b/node/test/package.test.ts
new file mode 100644
index 0000000..130a948
--- /dev/null
+++ b/node/test/package.test.ts
@@ -0,0 +1,151 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { test } from "node:test";
+
+const PACKAGE_ROOT = resolve(fileURLToPath(new URL("../..", import.meta.url)));
+const PACKAGE_NAME = "@aws/developer-toolkit-elasticache";
+const NPM_INSTALL_TIMEOUT_MS = 60_000;
+
+type PackedFile = {
+ path: string;
+};
+
+type PackResult = {
+ filename: string;
+ files: PackedFile[];
+};
+
+test("packed package installs and exposes its public API", () => {
+ const temporaryRoot = mkdtempSync(join(tmpdir(), "elasticache-package-test-"));
+ const packageDirectory = join(temporaryRoot, "package");
+ const installationDirectory = join(temporaryRoot, "installed");
+ const npmCacheDirectory = join(temporaryRoot, "npm-cache");
+ const npmCommand = process.env.npm_execpath
+ ? process.execPath
+ : process.platform === "win32"
+ ? "npm.cmd"
+ : "npm";
+ const npmCommandArgs = process.env.npm_execpath ? [process.env.npm_execpath] : [];
+ const npmSpawnOptions = {
+ cwd: PACKAGE_ROOT,
+ encoding: "utf8" as const,
+ env: {
+ ...process.env,
+ npm_config_cache: npmCacheDirectory,
+ npm_config_update_notifier: "false",
+ },
+ shell: process.platform === "win32" && !process.env.npm_execpath,
+ };
+
+ try {
+ mkdirSync(packageDirectory);
+ mkdirSync(installationDirectory);
+ const pack = spawnSync(
+ npmCommand,
+ [...npmCommandArgs, "pack", "--json", "--pack-destination", packageDirectory],
+ {
+ ...npmSpawnOptions,
+ },
+ );
+ assert.equal(pack.status, 0, pack.error?.message ?? pack.stderr);
+
+ const packResult = JSON.parse(pack.stdout) as PackResult[];
+ const packed = packResult[0];
+ assert.ok(packed);
+ assert.equal(
+ packed.files.some((file) => file.path === "CHANGELOG.md"),
+ true,
+ );
+ assert.equal(
+ packed.files.some((file) => file.path.startsWith("examples/")),
+ false,
+ );
+
+ const tarballPath = join(packageDirectory, packed.filename);
+ const install = spawnSync(
+ npmCommand,
+ [
+ ...npmCommandArgs,
+ "install",
+ tarballPath,
+ "--ignore-scripts",
+ "--no-audit",
+ "--no-fund",
+ "--prefix",
+ installationDirectory,
+ ],
+ {
+ ...npmSpawnOptions,
+ timeout: NPM_INSTALL_TIMEOUT_MS,
+ },
+ );
+ assert.equal(
+ install.status,
+ 0,
+ install.signal
+ ? `npm install was killed with ${install.signal} (likely no registry access; ` +
+ `this test installs the packed tarball's dependencies from the npm registry)`
+ : (install.error?.message ?? install.stderr),
+ );
+
+ const installedNodeModules = join(installationDirectory, "node_modules");
+ const exportsProbePath = join(installedNodeModules, "exports-probe.mjs");
+ writeFileSync(
+ exportsProbePath,
+ [
+ 'import assert from "node:assert/strict";',
+ 'import { ConfigurationError, ElastiCacheIAMAuthTokenManager, ElastiCacheIAMAuthTokenProvider, InvalidParameterError, TokenRefreshError, ToolkitInputError, generateIamAuthToken } from "@aws/developer-toolkit-elasticache";',
+ 'assert.equal(typeof generateIamAuthToken, "function");',
+ 'assert.equal(typeof ElastiCacheIAMAuthTokenProvider, "function");',
+ 'assert.equal(typeof ElastiCacheIAMAuthTokenManager, "function");',
+ "assert.equal(ConfigurationError.prototype instanceof ToolkitInputError, true);",
+ "assert.equal(ConfigurationError.prototype instanceof InvalidParameterError, false);",
+ "assert.equal(TokenRefreshError.prototype instanceof ToolkitInputError, false);",
+ "assert.equal(TokenRefreshError.prototype instanceof Error, true);",
+ ].join("\n"),
+ );
+ const exportsProbe = spawnSync(process.execPath, [exportsProbePath], {
+ cwd: installationDirectory,
+ encoding: "utf8",
+ });
+ assert.equal(
+ exportsProbe.status,
+ 0,
+ exportsProbe.error?.message ?? exportsProbe.stderr,
+ );
+ } finally {
+ rmSync(temporaryRoot, { recursive: true, force: true });
+ }
+});
+
+test("package root exports the complete public API", async () => {
+ const packageModule = await import(PACKAGE_NAME);
+
+ assert.equal(typeof packageModule.generateIamAuthToken, "function");
+ assert.equal(typeof packageModule.ElastiCacheIAMAuthTokenProvider, "function");
+ assert.equal(typeof packageModule.ElastiCacheIAMAuthTokenManager, "function");
+ assert.equal(typeof packageModule.ToolkitInputError, "function");
+ assert.equal(typeof packageModule.TokenRefreshError, "function");
+ assert.equal(typeof packageModule.InvalidParameterError, "function");
+ assert.equal(typeof packageModule.ConfigurationError, "function");
+ assert.equal(
+ packageModule.ConfigurationError.prototype instanceof packageModule.ToolkitInputError,
+ true,
+ );
+ assert.equal(
+ packageModule.ConfigurationError.prototype instanceof
+ packageModule.InvalidParameterError,
+ false,
+ );
+ assert.equal(
+ packageModule.TokenRefreshError.prototype instanceof packageModule.ToolkitInputError,
+ false,
+ );
+});
diff --git a/node/test/token-generator.test.ts b/node/test/token-generator.test.ts
new file mode 100644
index 0000000..46bf3a8
--- /dev/null
+++ b/node/test/token-generator.test.ts
@@ -0,0 +1,595 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import {
+ ConfigurationError,
+ ElastiCacheIAMAuthTokenProvider,
+ InvalidParameterError,
+ ToolkitInputError,
+ generateIamAuthToken,
+} from "../src/index.js";
+
+const CACHE = "my-cache";
+const USER = "testuser";
+const REGION = "us-east-1";
+const SESSION_REGION = "eu-west-1";
+const CREDENTIALS = {
+ accessKeyId: "AKIAIOSFODNN7EXAMPLE",
+ secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
+};
+const ROTATED_CREDENTIALS = {
+ accessKeyId: "AKIAI44QH8DHBEXAMPLE",
+ secretAccessKey: "other-secret",
+};
+const SIGNING_DATE = new Date("2025-01-01T00:00:00Z");
+
+function fixedDependencies(
+ credentials: typeof CREDENTIALS = CREDENTIALS,
+ region = REGION,
+) {
+ return {
+ credentialProvider: async () => credentials,
+ regionProvider: async () => region,
+ signingDate: SIGNING_DATE,
+ };
+}
+
+async function withoutRegion(callback: () => Promise): Promise {
+ // This helper is intentionally opt-in: only tests that exercise region fallback
+ // should be isolated from the developer's ambient AWS_REGION configuration.
+ const awsRegion = process.env.AWS_REGION;
+ const awsDefaultRegion = process.env.AWS_DEFAULT_REGION;
+ delete process.env.AWS_REGION;
+ delete process.env.AWS_DEFAULT_REGION;
+ try {
+ return await callback();
+ } finally {
+ if (awsRegion === undefined) {
+ delete process.env.AWS_REGION;
+ } else {
+ process.env.AWS_REGION = awsRegion;
+ }
+ if (awsDefaultRegion === undefined) {
+ delete process.env.AWS_DEFAULT_REGION;
+ } else {
+ process.env.AWS_DEFAULT_REGION = awsDefaultRegion;
+ }
+ }
+}
+
+// These known-answer tests pin the complete presigned token, including its
+// signature. A structural test could still pass after a signing-host, canonical
+// query, encoding, or credential-scope change that makes real authentication fail.
+test("generates the exact serverless known-answer token", async () => {
+ const token = await generateIamAuthToken(
+ { serverlessCacheName: CACHE, userId: USER, region: REGION },
+ fixedDependencies(),
+ );
+
+ assert.equal(
+ token,
+ "my-cache/?Action=connect&User=testuser&ResourceType=ServerlessCache" +
+ "&X-Amz-Algorithm=AWS4-HMAC-SHA256" +
+ "&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20250101%2Fus-east-1" +
+ "%2Felasticache%2Faws4_request" +
+ "&X-Amz-Date=20250101T000000Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host" +
+ "&X-Amz-Signature=" +
+ "28f349fd92f74e0192de149a74743ae7d087bb136523c342bdd67632a8360023",
+ );
+});
+
+test("generates the exact replication-group known-answer token", async () => {
+ const token = await generateIamAuthToken(
+ { replicationGroupId: CACHE, userId: USER, region: REGION },
+ fixedDependencies(),
+ );
+
+ assert.equal(
+ token,
+ "my-cache/?Action=connect&User=testuser" +
+ "&X-Amz-Algorithm=AWS4-HMAC-SHA256" +
+ "&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20250101%2Fus-east-1" +
+ "%2Felasticache%2Faws4_request" +
+ "&X-Amz-Date=20250101T000000Z&X-Amz-Expires=900&X-Amz-SignedHeaders=host" +
+ "&X-Amz-Signature=" +
+ "cd78a7de74c1bb1c8c685cac428f783b5433ce5fbf569af867570311d6a85797",
+ );
+ assert.equal(token.includes("ResourceType"), false);
+});
+
+test("normalizes the cache name and user id before signing", async () => {
+ const auth = new ElastiCacheIAMAuthTokenProvider(
+ {
+ serverlessCacheName: "My-Cache",
+ userId: "MixedCaseUser",
+ region: REGION,
+ },
+ fixedDependencies(),
+ );
+
+ assert.equal(auth.userId, "mixedcaseuser");
+ const token = await auth.getToken();
+ assert.equal(token.startsWith("my-cache/"), true);
+ assert.equal(token.includes("User=mixedcaseuser"), true);
+ assert.equal(token.includes("MixedCaseUser"), false);
+});
+
+test("accepts service-managed default users", async () => {
+ const token = await generateIamAuthToken(
+ { serverlessCacheName: CACHE, userId: "DEFAULT.IAM-USER", region: REGION },
+ fixedDependencies(),
+ );
+ assert.equal(token.includes("User=default.iam-user"), true);
+});
+
+test("requires exactly one target", async () => {
+ await assert.rejects(
+ () => generateIamAuthToken({ userId: USER, region: REGION }, fixedDependencies()),
+ InvalidParameterError,
+ );
+ await assert.rejects(
+ () =>
+ generateIamAuthToken(
+ {
+ userId: USER,
+ region: REGION,
+ serverlessCacheName: CACHE,
+ replicationGroupId: CACHE,
+ },
+ fixedDependencies(),
+ ),
+ InvalidParameterError,
+ );
+});
+
+test("rejects malformed cache names without echoing input", async () => {
+ const hostile = "evil.com/inject\nlog-entry";
+ await assert.rejects(
+ () =>
+ generateIamAuthToken(
+ { serverlessCacheName: hostile, userId: USER, region: REGION },
+ fixedDependencies(),
+ ),
+ (error: unknown) => {
+ assert.equal(error instanceof InvalidParameterError, true);
+ assert.equal((error as Error).message.includes("serverlessCacheName"), true);
+ assert.equal((error as Error).message.includes(hostile), false);
+ return true;
+ },
+ );
+});
+
+const INVALID_CACHE_NAMES = [
+ "evil.com/path", // slash / path injection into the signing host
+ "my-cache@evil.com", // userinfo host confusion
+ "my-cache/?X=1", // query injection into the host
+ "my cache", // whitespace
+ "my_cache", // underscore not allowed
+ "1-cache", // must start with a letter, not a digit
+ "-cache", // must start with a letter, not a hyphen
+ "cache-", // cannot end with a hyphen
+ "my--cache", // cannot contain two consecutive hyphens
+ "-", // a bare hyphen is neither
+];
+
+const VALID_CACHE_NAMES = [
+ "c", // a single letter is the shortest legal name
+ "my-cache",
+ "cache1",
+ "a-1-b-2", // hyphens between alphanumerics, repeatedly
+ "MyCache", // signed lowercase, but accepted as given
+];
+
+const INVALID_USER_IDS = [
+ "", // empty
+ "1user", // must start with a letter
+ "-user", // must start with a letter, not a hyphen
+ "user name", // whitespace
+ "user@host", // userinfo-style value
+ "user.name", // a dot is only allowed via the "default." prefix
+ "default.", // nothing after the prefix
+ "default.1x", // label after the prefix must start with a letter
+ "notdefault.foo", // the prefix must be literally "default."
+];
+
+const VALID_USER_IDS = [
+ "iam-user",
+ "myuser",
+ "default", // service-managed default user
+ "default.iam-user", // service-managed IAM user
+ "default.other", // any default.* is accepted
+ "DEFAULT.IAM-USER", // case-normalized before matching
+ "a-1-b-2",
+];
+
+for (const badName of INVALID_CACHE_NAMES) {
+ test(`rejects the malformed cache name ${JSON.stringify(badName)}`, async () => {
+ await assert.rejects(
+ () =>
+ generateIamAuthToken(
+ { serverlessCacheName: badName, userId: USER, region: REGION },
+ fixedDependencies(),
+ ),
+ (error: unknown) => {
+ assert.equal(error instanceof InvalidParameterError, true);
+ assert.equal((error as Error).message.includes("serverlessCacheName"), true);
+ return true;
+ },
+ );
+ });
+}
+
+for (const goodName of VALID_CACHE_NAMES) {
+ test(`accepts the cache name ${JSON.stringify(goodName)}`, async () => {
+ // Tightening the pattern must not reject names the service accepts.
+ const token = await generateIamAuthToken(
+ { serverlessCacheName: goodName, userId: USER, region: REGION },
+ fixedDependencies(),
+ );
+ assert.equal(token.startsWith(`${goodName.toLowerCase()}/`), true);
+ });
+}
+
+test("reports a bad replication group id under its own parameter name", async () => {
+ await assert.rejects(
+ () =>
+ generateIamAuthToken(
+ { replicationGroupId: "bad_name!", userId: USER, region: REGION },
+ fixedDependencies(),
+ ),
+ (error: unknown) => {
+ assert.equal(error instanceof InvalidParameterError, true);
+ assert.equal((error as Error).message.includes("replicationGroupId"), true);
+ return true;
+ },
+ );
+});
+
+test("treats an empty cache name as a missing target", async () => {
+ // An empty name is indistinguishable from no name, so it fails earlier.
+ await assert.rejects(
+ () =>
+ generateIamAuthToken(
+ { serverlessCacheName: "", userId: USER, region: REGION },
+ fixedDependencies(),
+ ),
+ InvalidParameterError,
+ );
+});
+
+for (const badUserId of INVALID_USER_IDS) {
+ test(`rejects the malformed user id ${JSON.stringify(badUserId)}`, async () => {
+ await assert.rejects(
+ () =>
+ generateIamAuthToken(
+ { serverlessCacheName: CACHE, userId: badUserId, region: REGION },
+ fixedDependencies(),
+ ),
+ (error: unknown) => {
+ assert.equal(error instanceof InvalidParameterError, true);
+ assert.equal((error as Error).message.includes("userId"), true);
+ return true;
+ },
+ );
+ });
+}
+
+for (const goodUserId of VALID_USER_IDS) {
+ test(`accepts the user id ${JSON.stringify(goodUserId)}`, async () => {
+ // Case does not matter: the id is lowercased before both matching and signing.
+ const token = await generateIamAuthToken(
+ { serverlessCacheName: CACHE, userId: goodUserId, region: REGION },
+ fixedDependencies(),
+ );
+ assert.equal(token.includes(`User=${goodUserId.toLowerCase()}`), true);
+ });
+}
+
+test("configuration errors are toolkit errors but not parameter errors", async () => {
+ assert.equal(ConfigurationError.prototype instanceof ToolkitInputError, true);
+ assert.equal(ConfigurationError.prototype instanceof InvalidParameterError, false);
+ await assert.rejects(
+ () =>
+ generateIamAuthToken(
+ { serverlessCacheName: CACHE, userId: USER, region: REGION },
+ {
+ credentialProvider: async () => {
+ throw new Error("credential chain failed");
+ },
+ signingDate: SIGNING_DATE,
+ },
+ ),
+ ConfigurationError,
+ );
+});
+
+test("normalizes rejected async credential providers", async () => {
+ const providerFailure = new Error("secret credential detail\nmust not escape");
+
+ await assert.rejects(
+ () =>
+ generateIamAuthToken(
+ { serverlessCacheName: CACHE, userId: USER, region: REGION },
+ {
+ credentialProvider: async () => {
+ throw providerFailure;
+ },
+ signingDate: SIGNING_DATE,
+ },
+ ),
+ (error: unknown) => {
+ assert.equal(error instanceof ConfigurationError, true);
+ assert.equal(
+ (error as Error).message,
+ "No AWS credentials found. Configure credentials via the environment, " +
+ "shared config/credentials files, or an instance/container role.",
+ );
+ assert.equal((error as Error).message.includes("secret credential detail"), false);
+ return true;
+ },
+ );
+});
+
+test("normalizes unusable async credential results", async () => {
+ await assert.rejects(
+ () =>
+ generateIamAuthToken(
+ { serverlessCacheName: CACHE, userId: USER, region: REGION },
+ {
+ credentialProvider: async () => ({
+ accessKeyId: "",
+ secretAccessKey: "not-a-usable-key",
+ }),
+ signingDate: SIGNING_DATE,
+ },
+ ),
+ ConfigurationError,
+ );
+});
+
+test("resolves explicit region before environment and provider", async () => {
+ const oldRegion = process.env.AWS_REGION;
+ process.env.AWS_REGION = "us-west-2";
+ try {
+ const token = await generateIamAuthToken(
+ { serverlessCacheName: CACHE, userId: USER, region: REGION },
+ {
+ ...fixedDependencies(),
+ regionProvider: async () => SESSION_REGION,
+ },
+ );
+ assert.equal(token.includes("%2Fus-east-1%2F"), true);
+ assert.equal(token.includes("%2Feu-west-1%2F"), false);
+ } finally {
+ if (oldRegion === undefined) {
+ delete process.env.AWS_REGION;
+ } else {
+ process.env.AWS_REGION = oldRegion;
+ }
+ }
+});
+
+test("resolves AWS_REGION before AWS_DEFAULT_REGION and shared config", async () => {
+ const oldRegion = process.env.AWS_REGION;
+ const oldDefaultRegion = process.env.AWS_DEFAULT_REGION;
+ process.env.AWS_REGION = "us-west-2";
+ process.env.AWS_DEFAULT_REGION = "ap-southeast-1";
+ try {
+ const token = await generateIamAuthToken(
+ { serverlessCacheName: CACHE, userId: USER },
+ {
+ credentialProvider: async () => CREDENTIALS,
+ regionProvider: async () => SESSION_REGION,
+ signingDate: SIGNING_DATE,
+ },
+ );
+ assert.equal(token.includes("%2Fus-west-2%2F"), true);
+ assert.equal(token.includes("%2Fap-southeast-1%2F"), false);
+ assert.equal(token.includes("%2Feu-west-1%2F"), false);
+ } finally {
+ if (oldRegion === undefined) {
+ delete process.env.AWS_REGION;
+ } else {
+ process.env.AWS_REGION = oldRegion;
+ }
+ if (oldDefaultRegion === undefined) {
+ delete process.env.AWS_DEFAULT_REGION;
+ } else {
+ process.env.AWS_DEFAULT_REGION = oldDefaultRegion;
+ }
+ }
+});
+
+test("resolves AWS_DEFAULT_REGION when AWS_REGION is absent", async () => {
+ await withoutRegion(async () => {
+ process.env.AWS_DEFAULT_REGION = "ap-southeast-1";
+ const token = await generateIamAuthToken(
+ { serverlessCacheName: CACHE, userId: USER },
+ {
+ ...fixedDependencies(CREDENTIALS, SESSION_REGION),
+ regionProvider: async () => "ap-northeast-1",
+ },
+ );
+ assert.equal(token.includes("%2Fap-southeast-1%2F"), true);
+ assert.equal(token.includes("%2Fap-northeast-1%2F"), false);
+ assert.equal(token.includes("%2Feu-west-1%2F"), false);
+ });
+});
+
+test("falls back to the injected shared region provider", async () => {
+ await withoutRegion(async () => {
+ const token = await generateIamAuthToken(
+ { serverlessCacheName: CACHE, userId: USER },
+ fixedDependencies(CREDENTIALS, SESSION_REGION),
+ );
+ assert.equal(token.includes("%2Feu-west-1%2F"), true);
+ });
+});
+
+test("normalizes rejected region providers", async () => {
+ await withoutRegion(async () => {
+ await assert.rejects(
+ () =>
+ generateIamAuthToken(
+ { serverlessCacheName: CACHE, userId: USER },
+ {
+ credentialProvider: async () => CREDENTIALS,
+ regionProvider: async () => {
+ throw new Error("private config path\nmust not escape");
+ },
+ },
+ ),
+ (error: unknown) => {
+ assert.equal(error instanceof ConfigurationError, true);
+ assert.equal(
+ (error as Error).message,
+ "No AWS region found. Pass region explicitly, or configure one via " +
+ "AWS_REGION, AWS_DEFAULT_REGION, or the region setting in your AWS config profile.",
+ );
+ assert.equal((error as Error).message.includes("private config path"), false);
+ return true;
+ },
+ );
+ });
+});
+
+test("raises a configuration error when no region is available", async () => {
+ await withoutRegion(async () => {
+ await assert.rejects(
+ () =>
+ generateIamAuthToken(
+ { serverlessCacheName: CACHE, userId: USER },
+ {
+ credentialProvider: async () => CREDENTIALS,
+ regionProvider: async () => undefined,
+ },
+ ),
+ (error: unknown) => {
+ assert.equal(error instanceof ConfigurationError, true);
+ assert.equal((error as Error).message.includes("AWS_REGION"), true);
+ assert.equal((error as Error).message.includes("AWS_DEFAULT_REGION"), true);
+ return true;
+ },
+ );
+ });
+});
+
+test("provider resolves credentials for every token request", async () => {
+ // Credentials are deliberately not cached: refreshed SSO, assume-role,
+ // container, and instance-metadata credentials must be used on the next request.
+ let calls = 0;
+ const auth = new ElastiCacheIAMAuthTokenProvider(
+ { serverlessCacheName: CACHE, userId: USER, region: REGION },
+ {
+ credentialProvider: async () => {
+ calls++;
+ return calls === 1 ? CREDENTIALS : ROTATED_CREDENTIALS;
+ },
+ signingDate: SIGNING_DATE,
+ },
+ );
+
+ const first = await auth.getToken();
+ const second = await auth.getToken();
+ assert.equal(calls, 2);
+ assert.notEqual(first, second);
+});
+
+test("provider retains its construction-time region while credentials rotate", async () => {
+ // The provider refreshes credentials but keeps the region used to construct it;
+ // changing the region source later must not change the signing scope.
+ await withoutRegion(async () => {
+ let regionSource = REGION;
+ let regionCalls = 0;
+ let credentialCalls = 0;
+ const auth = new ElastiCacheIAMAuthTokenProvider(
+ { serverlessCacheName: CACHE, userId: USER },
+ {
+ credentialProvider: async () => {
+ credentialCalls++;
+ return credentialCalls === 1 ? CREDENTIALS : ROTATED_CREDENTIALS;
+ },
+ regionProvider: async () => {
+ regionCalls++;
+ return regionSource;
+ },
+ signingDate: SIGNING_DATE,
+ },
+ );
+
+ regionSource = SESSION_REGION;
+ const first = await auth.getToken();
+ regionSource = "ap-southeast-1";
+ const second = await auth.getToken();
+
+ assert.equal(regionCalls, 1);
+ assert.equal(credentialCalls, 2);
+ assert.equal(first.includes("%2Fus-east-1%2F"), true);
+ assert.equal(first.includes("%2Feu-west-1%2F"), false);
+ assert.equal(second.includes("%2Fus-east-1%2F"), true);
+ assert.equal(second.includes("%2Fap-southeast-1%2F"), false);
+ });
+});
+
+test("create() fails immediately when no region is available", async () => {
+ // Fails during construction rather than at the first connection attempt.
+ await withoutRegion(async () => {
+ await assert.rejects(
+ () =>
+ ElastiCacheIAMAuthTokenProvider.create(
+ { serverlessCacheName: CACHE, userId: USER },
+ {
+ credentialProvider: async () => CREDENTIALS,
+ regionProvider: async () => undefined,
+ },
+ ),
+ (error: unknown) => {
+ assert.equal(error instanceof ConfigurationError, true);
+ assert.equal((error as Error).message.includes("AWS_REGION"), true);
+ return true;
+ },
+ );
+ });
+});
+
+test("create() validates its parameters before resolving the region", async () => {
+ await assert.rejects(
+ () =>
+ ElastiCacheIAMAuthTokenProvider.create(
+ { serverlessCacheName: "my_cache", userId: USER, region: REGION },
+ fixedDependencies(),
+ ),
+ InvalidParameterError,
+ );
+});
+
+test("create() returns a usable provider", async () => {
+ await withoutRegion(async () => {
+ const auth = await ElastiCacheIAMAuthTokenProvider.create(
+ { serverlessCacheName: CACHE, userId: USER },
+ fixedDependencies(CREDENTIALS, SESSION_REGION),
+ );
+
+ assert.equal(auth instanceof ElastiCacheIAMAuthTokenProvider, true);
+ assert.equal(auth.userId, USER);
+ const token = await auth.getToken();
+ assert.equal(token.includes("%2Feu-west-1%2F"), true);
+ });
+});
+
+test("provider reports async region configuration errors through getToken", async () => {
+ await withoutRegion(async () => {
+ const auth = new ElastiCacheIAMAuthTokenProvider(
+ { serverlessCacheName: CACHE, userId: USER },
+ {
+ credentialProvider: async () => CREDENTIALS,
+ regionProvider: async () => undefined,
+ },
+ );
+
+ await assert.rejects(() => auth.getToken(), ConfigurationError);
+ });
+});
diff --git a/node/test/token-manager.test.ts b/node/test/token-manager.test.ts
new file mode 100644
index 0000000..b793a90
--- /dev/null
+++ b/node/test/token-manager.test.ts
@@ -0,0 +1,603 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import assert from "node:assert/strict";
+import { test } from "node:test";
+
+import {
+ ElastiCacheIAMAuthTokenManager,
+ InvalidParameterError,
+ TokenRefreshError,
+} from "../src/index.js";
+import type { TokenManagerScheduler, TokenManagerTimer } from "../src/index.js";
+
+const CACHE = "my-cache";
+const USER = "testuser";
+const REGION = "us-east-1";
+const CREDENTIALS = {
+ accessKeyId: "AKIAIOSFODNN7EXAMPLE",
+ secretAccessKey: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
+};
+const ROTATED_CREDENTIALS = {
+ accessKeyId: "AKIAI44QH8DHBEXAMPLE",
+ secretAccessKey: "other-secret",
+};
+const SIGNING_DATE = new Date("2025-01-01T00:00:00Z");
+
+function flush(): Promise {
+ return new Promise((resolve) => setImmediate(resolve));
+}
+
+class FakeTimer implements TokenManagerTimer {
+ unrefCount = 0;
+ cancelled = false;
+
+ constructor(
+ readonly at: number,
+ readonly delayMs: number,
+ readonly callback: () => void,
+ ) {}
+
+ unref(): void {
+ this.unrefCount++;
+ }
+}
+
+class TestClock {
+ time = 0;
+ created: FakeTimer[] = [];
+ private pending: FakeTimer[] = [];
+
+ readonly now = (): number => this.time;
+
+ readonly scheduler: TokenManagerScheduler = {
+ setTimer: (callback, delayMs) => {
+ const timer = new FakeTimer(this.time + delayMs, delayMs, callback);
+ this.created.push(timer);
+ this.pending.push(timer);
+ return timer;
+ },
+ clearTimer: (timer) => {
+ const fake = timer as FakeTimer;
+ fake.cancelled = true;
+ this.pending = this.pending.filter((candidate) => candidate !== fake);
+ },
+ };
+
+ async advance(milliseconds: number): Promise {
+ const target = this.time + milliseconds;
+ while (true) {
+ const due = this.pending
+ .filter((timer) => !timer.cancelled && timer.at <= target)
+ .sort((left, right) => left.at - right.at)[0];
+ if (!due) {
+ break;
+ }
+ this.pending = this.pending.filter((timer) => timer !== due);
+ this.time = due.at;
+ due.callback();
+ await flush();
+ }
+ this.time = target;
+ await flush();
+ }
+}
+
+type Harness = {
+ clock: TestClock;
+ manager: ElastiCacheIAMAuthTokenManager;
+ credentialCalls: () => number;
+ changedTokens: string[];
+ fail: () => void;
+ succeed: () => void;
+};
+
+function harness(
+ overrides: {
+ refreshAfterSeconds?: number;
+ onTokenChanged?: (token: string) => void;
+ userId?: string;
+ } = {},
+): Harness {
+ const clock = new TestClock();
+ let calls = 0;
+ let failing = false;
+ const changedTokens: string[] = [];
+
+ const manager = new ElastiCacheIAMAuthTokenManager(
+ {
+ serverlessCacheName: CACHE,
+ userId: overrides.userId ?? USER,
+ region: REGION,
+ refreshAfterSeconds: overrides.refreshAfterSeconds,
+ onTokenChanged: overrides.onTokenChanged ?? ((token) => changedTokens.push(token)),
+ },
+ {
+ credentialProvider: async () => {
+ calls++;
+ if (failing) {
+ throw new Error("credential chain failed");
+ }
+ return calls === 1 ? CREDENTIALS : ROTATED_CREDENTIALS;
+ },
+ signingDate: SIGNING_DATE,
+ now: clock.now,
+ random: () => 0.5,
+ scheduler: clock.scheduler,
+ },
+ );
+
+ return {
+ clock,
+ manager,
+ credentialCalls: () => calls,
+ changedTokens,
+ fail: () => {
+ failing = true;
+ },
+ succeed: () => {
+ failing = false;
+ },
+ };
+}
+
+test("caches a token until its background refresh", async () => {
+ const { clock, manager, credentialCalls } = harness();
+ try {
+ const first = await manager.getToken();
+ const second = await manager.getToken();
+
+ assert.equal(second, first);
+ assert.equal(credentialCalls(), 1);
+ assert.equal(clock.created[0]?.delayMs, 300_000);
+ assert.equal(clock.created[0]?.unrefCount, 1);
+ } finally {
+ manager.close();
+ }
+});
+
+test("refreshes in the background and installs credentials that rotated", async () => {
+ const { clock, manager, credentialCalls, changedTokens } = harness();
+ try {
+ const first = await manager.getToken();
+ await clock.advance(300_000);
+ const second = await manager.getToken();
+
+ assert.equal(credentialCalls(), 2);
+ assert.notEqual(second, first);
+ assert.deepEqual(changedTokens, [first, second]);
+ } finally {
+ manager.close();
+ }
+});
+
+test("honours a custom refresh interval", async () => {
+ const { clock, manager, credentialCalls } = harness({ refreshAfterSeconds: 30 });
+ try {
+ await manager.getToken();
+ await clock.advance(29_999);
+ assert.equal(credentialCalls(), 1);
+ await clock.advance(1);
+ assert.equal(credentialCalls(), 2);
+ } finally {
+ manager.close();
+ }
+});
+
+test("shares one initial token mint across concurrent callers", async () => {
+ const { manager, credentialCalls } = harness();
+ try {
+ const [first, second, third] = await Promise.all([
+ manager.getToken(),
+ manager.getToken(),
+ manager.getToken(),
+ ]);
+
+ assert.equal(first, second);
+ assert.equal(second, third);
+ assert.equal(credentialCalls(), 1);
+ } finally {
+ manager.close();
+ }
+});
+
+test("returns the user id and cached token from the credential-provider hook", async () => {
+ const { manager, credentialCalls } = harness({ userId: "TestUser" });
+ try {
+ const [userId, token] = await manager.getCredentials();
+
+ assert.equal(userId, USER);
+ assert.equal(token, await manager.getToken());
+ assert.equal(credentialCalls(), 1);
+ } finally {
+ manager.close();
+ }
+});
+
+test("retries refresh failures with capped exponential backoff", async () => {
+ const { clock, manager, credentialCalls, fail } = harness();
+ fail();
+
+ const result = manager.getToken().then(
+ () => undefined,
+ (error: unknown) => error,
+ );
+ await flush();
+ await clock.advance(12_000);
+
+ assert.equal((await result) instanceof Error, true);
+ assert.equal(credentialCalls(), 8);
+ assert.deepEqual(
+ clock.created.map((timer) => timer.delayMs),
+ [100, 200, 400, 800, 1_600, 3_200, 5_000],
+ );
+ assert.equal(
+ clock.created.every((timer) => timer.unrefCount === 1),
+ true,
+ );
+ manager.close();
+});
+
+test("keeps serving a valid token while refresh credentials are unavailable", async () => {
+ const { clock, manager, credentialCalls, fail, succeed } = harness();
+ try {
+ const token = await manager.getToken();
+ fail();
+ await clock.advance(312_000);
+
+ assert.equal(clock.created.at(-1)?.delayMs, 5_000);
+ assert.equal(await manager.getToken(), token);
+ // The cooldown scheduled above is still pending, so this call must not
+ // start a redundant, concurrent refresh cycle of its own.
+ assert.equal(credentialCalls(), 9);
+
+ succeed();
+ await clock.advance(5_000);
+ assert.notEqual(await manager.getToken(), token);
+ } finally {
+ manager.close();
+ }
+});
+
+test("throws TokenRefreshError after the cached token expires", async () => {
+ const { clock, manager, fail } = harness({ refreshAfterSeconds: 879 });
+ try {
+ await manager.getToken();
+ fail();
+ await clock.advance(911_000);
+
+ const result = manager.getToken().then(
+ () => undefined,
+ (error: unknown) => error,
+ );
+ await flush();
+ await clock.advance(12_000);
+
+ const error = await result;
+ assert.equal(error instanceof TokenRefreshError, true);
+ assert.equal((error as Error).cause instanceof Error, true);
+ } finally {
+ manager.close();
+ }
+});
+
+test("ignores onTokenChanged callback failures", async () => {
+ const manager = harness({
+ onTokenChanged: () => {
+ throw new Error("consumer callback failed");
+ },
+ }).manager;
+ try {
+ assert.equal(typeof (await manager.getToken()), "string");
+ assert.equal(await manager.getToken(), await manager.getToken());
+ } finally {
+ manager.close();
+ }
+});
+
+test("ignores async onTokenChanged callback rejections", async () => {
+ const manager = harness({
+ onTokenChanged: async () => {
+ throw new Error("async consumer callback failed");
+ },
+ }).manager;
+ try {
+ assert.equal(typeof (await manager.getToken()), "string");
+ await flush();
+ } finally {
+ manager.close();
+ }
+});
+
+test("close cancels background work and rejects later requests", async () => {
+ const { clock, manager, credentialCalls } = harness();
+ await manager.getToken();
+ manager.close();
+ await clock.advance(300_000);
+
+ assert.equal(credentialCalls(), 1);
+ await assert.rejects(() => manager.getToken(), TokenRefreshError);
+});
+
+test("close prevents retry when an in-flight mint fails", async () => {
+ const clock = new TestClock();
+ let rejectCredentials!: (reason: Error) => void;
+ const credentials = new Promise((_resolve, reject) => {
+ rejectCredentials = reject;
+ });
+ const manager = new ElastiCacheIAMAuthTokenManager(
+ {
+ serverlessCacheName: CACHE,
+ userId: USER,
+ region: REGION,
+ },
+ {
+ credentialProvider: () => credentials,
+ signingDate: SIGNING_DATE,
+ now: clock.now,
+ random: () => 0.5,
+ scheduler: clock.scheduler,
+ },
+ );
+ const result = manager.getToken();
+ await flush();
+ manager.close();
+
+ const rejection = assert.rejects(result, TokenRefreshError);
+ rejectCredentials(new Error("credential chain failed"));
+ await rejection;
+ assert.equal(clock.created.length, 0);
+});
+
+test("close discards a successful in-flight mint", async () => {
+ const clock = new TestClock();
+ let resolveCredentials!: (credentials: typeof CREDENTIALS) => void;
+ const credentials = new Promise((resolve) => {
+ resolveCredentials = resolve;
+ });
+ const changedTokens: string[] = [];
+ const manager = new ElastiCacheIAMAuthTokenManager(
+ {
+ serverlessCacheName: CACHE,
+ userId: USER,
+ region: REGION,
+ onTokenChanged: (token) => changedTokens.push(token),
+ },
+ {
+ credentialProvider: () => credentials,
+ signingDate: SIGNING_DATE,
+ now: clock.now,
+ random: () => 0.5,
+ scheduler: clock.scheduler,
+ },
+ );
+ const result = manager.getToken();
+ await flush();
+ manager.close();
+
+ const rejection = assert.rejects(result, TokenRefreshError);
+ resolveCredentials(CREDENTIALS);
+ await rejection;
+ assert.deepEqual(changedTokens, []);
+ assert.equal(clock.created.length, 0);
+});
+
+for (const refreshAfterSeconds of [
+ 0,
+ -1,
+ 880,
+ 899,
+ 900,
+ Number.NaN,
+ Number.POSITIVE_INFINITY,
+]) {
+ test(`rejects invalid refreshAfterSeconds ${String(refreshAfterSeconds)}`, () => {
+ assert.throws(
+ () =>
+ new ElastiCacheIAMAuthTokenManager({
+ serverlessCacheName: CACHE,
+ userId: USER,
+ region: REGION,
+ refreshAfterSeconds,
+ }),
+ InvalidParameterError,
+ );
+ });
+}
+
+test("accepts a refreshAfterSeconds just below the effective token lifetime", () => {
+ assert.doesNotThrow(() =>
+ new ElastiCacheIAMAuthTokenManager({
+ serverlessCacheName: CACHE,
+ userId: USER,
+ region: REGION,
+ refreshAfterSeconds: 879,
+ }).close(),
+ );
+});
+
+test("treats a cached token as expired at the 880 second margin, not the raw 900 second lifetime", async () => {
+ const { clock, manager, fail } = harness({ refreshAfterSeconds: 879 });
+ try {
+ await manager.getToken();
+ fail();
+ // The background cycle started at refreshAt (879s) exhausts its 8 attempts
+ // shortly after 890s: past the 880s effective expiry, but short of the raw
+ // 900s token lifetime. Under the raw lifetime this would still be "valid"
+ // and the cycle would fall back to serving the stale token instead.
+ await clock.advance(891_000);
+
+ const result = manager.getToken().then(
+ () => undefined,
+ (error: unknown) => error,
+ );
+ await flush();
+ await clock.advance(12_000);
+
+ assert.ok((await result) instanceof TokenRefreshError);
+ } finally {
+ manager.close();
+ }
+});
+
+test("does not start a second refresh cycle while one is scheduled during cooldown", async () => {
+ const { clock, manager, credentialCalls, fail, succeed } = harness();
+ try {
+ const token = await manager.getToken();
+ fail();
+ // Run the first refresh cycle to exhaustion; it falls back to serving the
+ // still-valid cached token and schedules a retry after MAX_DELAY_MS.
+ await clock.advance(311_300);
+ const callsAfterFirstCycle = credentialCalls();
+ assert.equal(clock.created.at(-1)?.delayMs, 5_000);
+
+ // Many concurrent reconnect-storm callers hit the cooldown window before
+ // the scheduled retry fires. None of them should start another cycle.
+ const results = await Promise.all(
+ Array.from({ length: 20 }, () => manager.getToken()),
+ );
+ assert.equal(
+ results.every((result) => result === token),
+ true,
+ );
+ assert.equal(credentialCalls(), callsAfterFirstCycle);
+
+ succeed();
+ await clock.advance(5_000);
+ assert.notEqual(await manager.getToken(), token);
+ } finally {
+ manager.close();
+ }
+});
+
+test("refreshToken forces a new token ahead of the scheduled refresh", async () => {
+ const { manager, credentialCalls, changedTokens } = harness();
+ try {
+ const first = await manager.getToken();
+ const second = await manager.refreshToken();
+
+ assert.notEqual(second, first);
+ assert.equal(await manager.getToken(), second);
+ assert.equal(credentialCalls(), 2);
+ assert.deepEqual(changedTokens, [first, second]);
+ } finally {
+ manager.close();
+ }
+});
+
+test("refreshToken shares one mint across concurrent callers", async () => {
+ const { manager, credentialCalls } = harness();
+ try {
+ await manager.getToken();
+ const [first, second, third] = await Promise.all([
+ manager.refreshToken(),
+ manager.refreshToken(),
+ manager.refreshToken(),
+ ]);
+
+ assert.equal(first, second);
+ assert.equal(second, third);
+ assert.equal(credentialCalls(), 2);
+ } finally {
+ manager.close();
+ }
+});
+
+test("refreshToken joins an already in-flight background refresh", async () => {
+ const { clock, manager, credentialCalls } = harness();
+ try {
+ const first = await manager.getToken();
+ // Cross refreshAt without going through clock.advance()'s timer-firing loop,
+ // so the background cycle it starts is still in flight below.
+ clock.time = 300_000;
+ const background = manager.getToken();
+ const forced = manager.refreshToken();
+ const [backgroundToken, forcedToken] = await Promise.all([background, forced]);
+
+ assert.equal(backgroundToken, first);
+ assert.notEqual(forcedToken, first);
+ assert.equal(credentialCalls(), 2);
+ assert.equal(await manager.getToken(), forcedToken);
+ } finally {
+ manager.close();
+ }
+});
+
+test("refreshToken invalidates the cache while joining a failing background refresh", async () => {
+ const { clock, manager, credentialCalls, fail } = harness();
+ try {
+ const token = await manager.getToken();
+ fail();
+ clock.time = 300_000;
+
+ const backgroundToken = manager.getToken();
+ const forcedResult = manager.refreshToken().then(
+ () => undefined,
+ (error: unknown) => error,
+ );
+ const concurrentResult = manager.getToken().then(
+ () => undefined,
+ (error: unknown) => error,
+ );
+
+ assert.equal(await backgroundToken, token);
+ await flush();
+ await clock.advance(12_000);
+
+ assert.ok((await forcedResult) instanceof Error);
+ assert.ok((await concurrentResult) instanceof Error);
+ assert.equal(credentialCalls(), 9);
+ } finally {
+ manager.close();
+ }
+});
+
+test("refreshToken rejects without a cached token when every attempt fails", async () => {
+ const { clock, manager, fail } = harness();
+ fail();
+
+ const result = manager.refreshToken().then(
+ () => undefined,
+ (error: unknown) => error,
+ );
+ await flush();
+ await clock.advance(12_000);
+
+ assert.equal((await result) instanceof Error, true);
+ manager.close();
+});
+
+test("refreshToken rejects after close", async () => {
+ const { manager } = harness();
+ manager.close();
+ await assert.rejects(() => manager.refreshToken(), TokenRefreshError);
+});
+
+test("close aborts an in-flight refreshToken call", async () => {
+ const clock = new TestClock();
+ let resolveCredentials!: (credentials: typeof CREDENTIALS) => void;
+ const credentials = new Promise((resolve) => {
+ resolveCredentials = resolve;
+ });
+ const manager = new ElastiCacheIAMAuthTokenManager(
+ {
+ serverlessCacheName: CACHE,
+ userId: USER,
+ region: REGION,
+ },
+ {
+ credentialProvider: () => credentials,
+ signingDate: SIGNING_DATE,
+ now: clock.now,
+ random: () => 0.5,
+ scheduler: clock.scheduler,
+ },
+ );
+ const result = manager.refreshToken();
+ await flush();
+ manager.close();
+
+ const rejection = assert.rejects(result, TokenRefreshError);
+ resolveCredentials(CREDENTIALS);
+ await rejection;
+});
diff --git a/node/test/verify.test.ts b/node/test/verify.test.ts
new file mode 100644
index 0000000..261bf2f
--- /dev/null
+++ b/node/test/verify.test.ts
@@ -0,0 +1,156 @@
+// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+// SPDX-License-Identifier: Apache-2.0
+
+import assert from "node:assert/strict";
+import { spawnSync } from "node:child_process";
+import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+import { test } from "node:test";
+
+const PACKAGE_ROOT = resolve(fileURLToPath(new URL("../..", import.meta.url)));
+const SCRIPT_PATH = join(PACKAGE_ROOT, "scripts", "verify.mjs");
+
+const RELEASABLE = {
+ name: "@aws/developer-toolkit-elasticache",
+ version: "1.2.3",
+ repository: {
+ type: "git",
+ url: "git+https://github.com/aws/developer-toolkit-elasticache.git",
+ },
+};
+const CHANGELOG_WITH_ENTRY = "# Changelog\n\n## [1.2.3] - 2026-01-01\n\n- Something.\n";
+
+function runVerify(
+ args: string[],
+ packageJson: Record,
+ changelog = CHANGELOG_WITH_ENTRY,
+ env: Record = { GITHUB_REF_NAME: "" },
+) {
+ const temporaryRoot = mkdtempSync(join(tmpdir(), "elasticache-node-verify-"));
+ const scriptsDirectory = join(temporaryRoot, "scripts");
+ const temporaryScript = join(scriptsDirectory, "verify.mjs");
+
+ try {
+ mkdirSync(scriptsDirectory);
+ copyFileSync(SCRIPT_PATH, temporaryScript);
+ writeFileSync(join(temporaryRoot, "package.json"), JSON.stringify(packageJson));
+ writeFileSync(join(temporaryRoot, "CHANGELOG.md"), changelog);
+ return spawnSync(process.execPath, [temporaryScript, ...args], {
+ encoding: "utf8",
+ env: { ...process.env, ...env },
+ });
+ } finally {
+ rmSync(temporaryRoot, { recursive: true, force: true });
+ }
+}
+
+test("accepts a matching tag on a publishable package", () => {
+ const result = runVerify(["node-v1.2.3"], RELEASABLE);
+
+ assert.equal(result.status, 0, result.stdout + result.stderr);
+ assert.match(result.stdout, /Publishing version 1\.2\.3\./);
+});
+
+test("reads the tag from GITHUB_REF_NAME when no argument is given", () => {
+ const result = runVerify([], RELEASABLE, CHANGELOG_WITH_ENTRY, {
+ GITHUB_REF_NAME: "node-v1.2.3",
+ });
+
+ assert.equal(result.status, 0, result.stdout + result.stderr);
+});
+
+const rejectedCases: {
+ name: string;
+ args: string[];
+ packageJson: Record;
+ changelog?: string;
+ expectedError: RegExp;
+}[] = [
+ {
+ name: "rejects a tag that does not match the version",
+ args: ["node-v1.2.4"],
+ packageJson: RELEASABLE,
+ expectedError: /does not match the version in package\.json \(1\.2\.3\)/,
+ },
+ {
+ name: "rejects a tag without the node-v prefix",
+ args: ["v1.2.3"],
+ packageJson: RELEASABLE,
+ expectedError: /Expected node-v1\.2\.3/,
+ },
+ {
+ name: "rejects a package still marked private",
+ args: ["node-v1.2.3"],
+ packageJson: { ...RELEASABLE, private: true },
+ expectedError: /"private": true/,
+ },
+ {
+ name: "rejects a missing repository url",
+ args: ["node-v1.2.3"],
+ packageJson: { name: RELEASABLE.name, version: RELEASABLE.version },
+ expectedError: /repository\.url must point at/,
+ },
+ {
+ name: "rejects a changelog without an entry for the version",
+ args: ["node-v1.2.3"],
+ packageJson: RELEASABLE,
+ changelog: "# Changelog\n\n## [Unreleased]\n\n- Something.\n",
+ expectedError: /CHANGELOG\.md has no "## \[1\.2\.3\]" entry/,
+ },
+ {
+ name: "rejects a release candidate",
+ args: ["node-v1.2.3-rc.1"],
+ packageJson: { ...RELEASABLE, version: "1.2.3-rc.1" },
+ changelog: "# Changelog\n\n## [1.2.3-rc.1] - 2026-01-01\n\n- Something.\n",
+ expectedError: /1\.2\.3-rc\.1 is a prerelease/,
+ },
+ {
+ name: "rejects a dev version",
+ args: ["node-v0.1.0-dev.0"],
+ packageJson: { ...RELEASABLE, version: "0.1.0-dev.0" },
+ changelog: "# Changelog\n\n## [0.1.0-dev.0] - 2026-01-01\n\n- Something.\n",
+ expectedError: /0\.1\.0-dev\.0 is a prerelease/,
+ },
+ {
+ name: "rejects --require-npm without a value",
+ args: ["node-v1.2.3", "--require-npm"],
+ packageJson: RELEASABLE,
+ expectedError: /--require-npm needs a version/,
+ },
+ {
+ name: "rejects --require-npm followed by another flag",
+ args: ["node-v1.2.3", "--require-npm", "--whatever"],
+ packageJson: RELEASABLE,
+ expectedError: /--require-npm needs a version/,
+ },
+ {
+ name: "rejects a non-numeric --require-npm value",
+ args: ["node-v1.2.3", "--require-npm", "eleven"],
+ packageJson: RELEASABLE,
+ expectedError: /not a dotted numeric version/,
+ },
+ {
+ name: "rejects an npm older than the required version",
+ args: ["node-v1.2.3", "--require-npm", "999.0.0"],
+ packageJson: RELEASABLE,
+ expectedError: /older than 999\.0\.0/,
+ },
+];
+
+for (const testCase of rejectedCases) {
+ test(testCase.name, () => {
+ const result = runVerify(testCase.args, testCase.packageJson, testCase.changelog);
+
+ assert.notEqual(result.status, 0);
+ assert.match(result.stdout, /^::error::/m);
+ assert.match(result.stdout, testCase.expectedError);
+ });
+}
+
+test("accepts an npm at or above the required version", () => {
+ const result = runVerify(["node-v1.2.3", "--require-npm", "1.0.0"], RELEASABLE);
+
+ assert.equal(result.status, 0, result.stdout + result.stderr);
+});
diff --git a/node/tsconfig.json b/node/tsconfig.json
new file mode 100644
index 0000000..dfeb093
--- /dev/null
+++ b/node/tsconfig.json
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "declaration": true,
+ "declarationMap": true,
+ "forceConsistentCasingInFileNames": true,
+ "lib": ["ES2022"],
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "noEmitOnError": true,
+ "outDir": "dist",
+ "rootDir": "src",
+ "skipLibCheck": true,
+ "sourceMap": true,
+ "strict": true,
+ "target": "ES2022",
+ "types": ["node"]
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/node/tsconfig.test.json b/node/tsconfig.test.json
new file mode 100644
index 0000000..4cf4d89
--- /dev/null
+++ b/node/tsconfig.test.json
@@ -0,0 +1,10 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "declaration": false,
+ "declarationMap": false,
+ "outDir": "dist-test",
+ "rootDir": "."
+ },
+ "include": ["src/**/*.ts", "test/**/*.ts"]
+}
diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md
index 9f64289..8fa20de 100644
--- a/python/CHANGELOG.md
+++ b/python/CHANGELOG.md
@@ -4,6 +4,28 @@ All notable changes to the Python package are documented here. The format follow
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the package uses
[Semantic Versioning](https://semver.org/).
+## [1.1.0] - 2026-10-21
+
+### Added
+
+- `ElastiCacheIAMAuthTokenManager` — caches a token for 880 seconds (its 900-second
+ lifetime minus a 20-second serve margin) and refreshes it on a background daemon
+ thread `refresh_after` seconds after signing (default 300). Concurrent callers share
+ a single signing cycle and its outcome; failed refreshes are retried with jittered
+ exponential backoff while the current token is still served, with a warning logged
+ per failed cycle; `on_token_changed` reports each new token; `get_credentials()`
+ returns `(user_id, token)`; `refresh_token()` signs a replacement on demand;
+ `close()` or a `with` block stops background work.
+- `TokenRefreshError` — raised by the manager when a cached token expired before a
+ refresh could replace it, or after `close()`; `__cause__` is the last refresh error.
+ Not a `ToolkitInputError`.
+- `DEFAULT_REFRESH_AFTER_SECONDS` constant.
+- `credentials` parameter on `generate_iam_auth_token()`,
+ `ElastiCacheIAMAuthTokenProvider`, and `ElastiCacheIAMAuthTokenManager` — signs with a
+ botocore credentials object the caller already holds, such as the one behind an existing
+ boto3 client, instead of resolving the credential chain. Takes precedence over `session`,
+ and is re-read for every token, so a refreshable object's rotation is still picked up.
+
## [1.0.0] - 2026-09-15
Initial release.
@@ -21,4 +43,5 @@ Initial release.
caller-provided values.
- Examples for redis-py and valkey-py under `examples/`.
+[1.1.0]: https://github.com/aws/developer-toolkit-elasticache/releases/tag/python-v1.1.0
[1.0.0]: https://github.com/aws/developer-toolkit-elasticache/releases/tag/python-v1.0.0
diff --git a/python/README.md b/python/README.md
index 31211f1..7ee5ed6 100644
--- a/python/README.md
+++ b/python/README.md
@@ -69,6 +69,31 @@ 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.
+
+**Bringing your own credentials**
+
+Pass `credentials` to sign with a botocore credentials object you already hold — the one
+behind an existing boto3 client, for instance — instead of resolving the chain:
+
+```python
+from developer_toolkit_elasticache import generate_iam_auth_token
+
+token = generate_iam_auth_token(
+ serverless_cache_name="my-cache",
+ user_id="default.iam-user",
+ region="us-east-1",
+ credentials=credentials,
+)
+```
+
+`credentials` takes precedence over `session` and is accepted by
+`ElastiCacheIAMAuthTokenProvider` and `ElastiCacheIAMAuthTokenManager` too. It is re-read
+for every token, so a refreshable object (SSO, assume-role, container, instance metadata)
+keeps rotating exactly as the credential chain would.
+
### Reconnecting clients
Clients such as redis-py and valkey-py request credentials on every reconnection.
@@ -88,6 +113,72 @@ username, password = auth.user_id, auth.get_token()
The [`examples/`](examples/) directory has redis-py and valkey-py integrations.
+### Caching and background refresh
+
+`ElastiCacheIAMAuthTokenProvider` is stateless: every `get_token()` call resolves
+credentials and signs a new token. `ElastiCacheIAMAuthTokenManager` adds caching on
+top of that:
+
+| | `ElastiCacheIAMAuthTokenProvider` | `ElastiCacheIAMAuthTokenManager` |
+| ---------------------- | --------------------------------- | ----------------------------------------------- |
+| Token per `get_token()`| A freshly signed token every call | The cached token while it is valid |
+| Refresh | Caller-driven | Background, `refresh_after` seconds after signing |
+| Refresh failures | Raised to the caller | Retried, with the current token still served |
+| Change notification | None | `on_token_changed` |
+| Cleanup | None needed | `close()`, or use as a context manager |
+
+Use the manager when a client reconnects often or asks for a token more often than
+the 900-second token lifetime; use the provider when each call should sign a new
+token.
+
+```python
+from developer_toolkit_elasticache import ElastiCacheIAMAuthTokenManager
+
+with ElastiCacheIAMAuthTokenManager(
+ serverless_cache_name="my-cache",
+ user_id="iam-user",
+ region="us-east-1",
+ # Optional; defaults to 300. Must be greater than 0 and less than 880.
+ refresh_after=300,
+ on_token_changed=lambda token: print("installed a new IAM auth token"),
+) as auth:
+ username, password = auth.get_credentials()
+```
+
+`get_credentials()` returns `(user_id, token)` for clients that take a no-argument
+credentials callable; `get_token()` remains available when the client takes the
+username and password separately. `refresh_token()` signs a replacement immediately
+and returns it, for a client whose `AUTH` was rejected with a token the manager still
+considers valid (for example after the signing credentials were revoked); it raises
+`TokenRefreshError` if no replacement could be signed.
+
+The region is resolved when the manager is created, but no credentials are resolved
+and no token is signed until the first `get_token()` or `get_credentials()` call.
+After that, `get_token()` returns the cached token immediately while it is valid. A
+token is served for 880 seconds after signing — its 900-second lifetime minus a
+20-second margin for clock skew and the `AUTH` round trip — and `refresh_after` must
+be below that. A background thread signs a replacement `refresh_after` seconds after
+each token is issued; concurrent callers with no valid token share a single signing
+and receive the same outcome.
+
+A refresh makes up to 8 attempts, the first immediate and the rest with exponential
+backoff from 100 ms to a nominal cap of 5 seconds, each delay jittered by ±20%. Every
+error is retried, including `ConfigurationError`: on an instance role a transient
+IMDS failure surfaces as "no credentials". A `get_token()` call with no valid token
+blocks for that cycle, so it can take several seconds when signing keeps failing.
+
+While the current token is still valid a failed refresh is invisible to callers: the
+cached token keeps being served, another refresh is scheduled, and a warning is
+logged to the `developer_toolkit_elasticache.token_manager` logger. `get_token()`
+raises `TokenRefreshError` only if the cached token expired before a refresh could
+replace it, or after `close()`; its `__cause__` is the error from the last attempt. A
+failure to sign the very first token is raised as the underlying error instead.
+
+`on_token_changed` must be a synchronous callable; if it raises, the error is logged
+and the new token is kept. The refresh thread is a daemon, so it never keeps the
+interpreter alive on its own. Call `close()` (or leave the `with` block) when the
+manager is no longer needed.
+
### Command line
```bash
diff --git a/python/pyproject.toml b/python/pyproject.toml
index c866eac..fa4d783 100644
--- a/python/pyproject.toml
+++ b/python/pyproject.toml
@@ -10,7 +10,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "developer-toolkit-elasticache"
-version = "1.0.0"
+version = "1.1.0"
description = "Developer toolkit for Amazon ElastiCache"
readme = "README.md"
license = "Apache-2.0"
diff --git a/python/src/developer_toolkit_elasticache/__init__.py b/python/src/developer_toolkit_elasticache/__init__.py
index 0f2b49b..aa6c80d 100644
--- a/python/src/developer_toolkit_elasticache/__init__.py
+++ b/python/src/developer_toolkit_elasticache/__init__.py
@@ -6,17 +6,25 @@
from developer_toolkit_elasticache.errors import (
ConfigurationError,
InvalidParameterError,
+ TokenRefreshError,
ToolkitInputError,
)
from developer_toolkit_elasticache.token_generator import (
ElastiCacheIAMAuthTokenProvider,
generate_iam_auth_token,
)
+from developer_toolkit_elasticache.token_manager import (
+ DEFAULT_REFRESH_AFTER_SECONDS,
+ ElastiCacheIAMAuthTokenManager,
+)
__all__ = [
+ "DEFAULT_REFRESH_AFTER_SECONDS",
"ConfigurationError",
+ "ElastiCacheIAMAuthTokenManager",
"ElastiCacheIAMAuthTokenProvider",
"InvalidParameterError",
+ "TokenRefreshError",
"ToolkitInputError",
"generate_iam_auth_token",
]
diff --git a/python/src/developer_toolkit_elasticache/errors.py b/python/src/developer_toolkit_elasticache/errors.py
index 72b507b..2050c91 100644
--- a/python/src/developer_toolkit_elasticache/errors.py
+++ b/python/src/developer_toolkit_elasticache/errors.py
@@ -33,3 +33,13 @@ class ConfigurationError(ToolkitInputError):
Covers the missing-configuration cases (no region or no credentials).
"""
+
+
+class TokenRefreshError(Exception):
+ """A cached token could not be refreshed before it expired, or the token
+ manager was closed.
+
+ Raised only by ``ElastiCacheIAMAuthTokenManager``. This is an operational
+ failure rather than an input error, so it does not extend ``ToolkitInputError``.
+ When a refresh failed, ``__cause__`` is the error from the last refresh attempt.
+ """
diff --git a/python/src/developer_toolkit_elasticache/token_generator.py b/python/src/developer_toolkit_elasticache/token_generator.py
index aa9944a..62a1196 100644
--- a/python/src/developer_toolkit_elasticache/token_generator.py
+++ b/python/src/developer_toolkit_elasticache/token_generator.py
@@ -16,7 +16,7 @@
)
if TYPE_CHECKING:
- from botocore.credentials import ReadOnlyCredentials
+ from botocore.credentials import Credentials, ReadOnlyCredentials
_TOKEN_TTL_SECONDS = 900 # 15 minutes
@@ -104,13 +104,16 @@ def _resolve_region(region: str | None, session: Session) -> str:
return resolved
-def _resolve_credentials(session: Session) -> "ReadOnlyCredentials":
- """Read a frozen credential set from the session's credential chain.
+def _resolve_credentials(
+ session: Session, credentials: "Credentials | None" = None
+) -> "ReadOnlyCredentials":
+ """Read a frozen credential set from ``credentials``, or the session's credential chain.
Called on every token request rather than cached, so rotated credentials
- (SSO, assume-role, container, instance metadata) are picked up automatically.
+ (SSO, assume-role, container, instance metadata) are picked up automatically,
+ including through a supplied credentials object, which refreshes itself.
"""
- credentials = session.get_credentials()
+ credentials = credentials or session.get_credentials()
if credentials is None:
raise ConfigurationError(_NO_CREDENTIALS_MESSAGE)
return credentials.get_frozen_credentials()
@@ -156,13 +159,14 @@ def _sign_token(
return request.url.removeprefix(_URL_SCHEME_PREFIX)
-def generate_iam_auth_token(
+def generate_iam_auth_token( # noqa: PLR0913 # keyword-only signing parameters
*,
user_id: str,
region: str | None = None,
serverless_cache_name: str | None = None,
replication_group_id: str | None = None,
session: Session | None = None,
+ credentials: "Credentials | None" = None,
) -> str:
"""Generate an ElastiCache IAM auth token using the default AWS credential chain.
@@ -176,14 +180,16 @@ def generate_iam_auth_token(
passed to sign for a region other than the configured one.
Pass ``session`` to sign with a specific botocore session; by default a new
- session resolves credentials from the standard chain.
+ session resolves credentials from the standard chain. Pass ``credentials`` to sign
+ with a credentials object you already hold, such as the one behind an existing
+ client; it takes precedence over the session.
"""
cache_name, serverless = _resolve_target(serverless_cache_name, replication_group_id)
session = session or Session()
resolved_region = _resolve_region(region, session)
- credentials = _resolve_credentials(session)
+ resolved_credentials = _resolve_credentials(session, credentials)
return _sign_token(
- cache_name, user_id, resolved_region, credentials, serverless=serverless
+ cache_name, user_id, resolved_region, resolved_credentials, serverless=serverless
)
@@ -199,13 +205,14 @@ class ElastiCacheIAMAuthTokenProvider:
``region`` defaults to the region the session resolves, so it only needs to be
passed to sign for a region other than the configured one.
- Credentials come from the default AWS credential chain (env vars, shared
- config/credentials, or the instance/container role). They are re-read on
+ Credentials come from ``credentials`` when supplied, otherwise from the default AWS
+ credential chain (env vars, shared config/credentials, or the instance/container
+ role). They are re-read on
every call, so rotated credentials (SSO, assume-role, container, instance
metadata) are picked up automatically.
"""
- def __init__(
+ def __init__( # noqa: PLR0913 # keyword-only signing parameters
self,
*,
user_id: str,
@@ -213,11 +220,13 @@ def __init__(
serverless_cache_name: str | None = None,
replication_group_id: str | None = None,
session: Session | None = None,
+ credentials: "Credentials | None" = None,
):
self._cache_name, self._serverless = _resolve_target(
serverless_cache_name, replication_group_id
)
self._user_id = _validate_and_normalize("user_id", user_id, _USER_ID_PATTERN)
+ self._credentials = credentials
self._session = session or Session()
# Resolved once here rather than per call: unlike credentials, the region is
# static configuration, and a missing region should surface at construction
@@ -230,7 +239,7 @@ def user_id(self) -> str:
def get_token(self) -> str:
"""Sign and return a fresh IAM auth token."""
- credentials = _resolve_credentials(self._session)
+ credentials = _resolve_credentials(self._session, self._credentials)
return _sign_token(
self._cache_name,
self._user_id,
diff --git a/python/src/developer_toolkit_elasticache/token_manager.py b/python/src/developer_toolkit_elasticache/token_manager.py
new file mode 100644
index 0000000..016bd01
--- /dev/null
+++ b/python/src/developer_toolkit_elasticache/token_manager.py
@@ -0,0 +1,382 @@
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+"""A cached, self-refreshing IAM auth token for Amazon ElastiCache.
+
+``ElastiCacheIAMAuthTokenManager`` wraps the stateless provider with a cache that is
+refreshed in the background, so callers always receive a valid token without signing
+on every call.
+"""
+
+import logging
+import math
+import random
+import threading
+import time
+from collections.abc import Callable
+from concurrent.futures import Future
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Protocol
+
+from botocore.session import Session
+
+if TYPE_CHECKING:
+ from botocore.credentials import Credentials
+
+from developer_toolkit_elasticache.errors import InvalidParameterError, TokenRefreshError
+from developer_toolkit_elasticache.token_generator import (
+ _TOKEN_TTL_SECONDS,
+ ElastiCacheIAMAuthTokenProvider,
+)
+
+_logger = logging.getLogger(__name__)
+
+# Stop serving a token this long before its 900s lifetime ends, to absorb clock skew
+# and the AUTH round trip.
+_SERVE_MARGIN_SECONDS = 20
+_EFFECTIVE_LIFETIME_SECONDS = _TOKEN_TTL_SECONDS - _SERVE_MARGIN_SECONDS
+
+# A third of the token lifetime: two refresh windows remain if this one fails.
+DEFAULT_REFRESH_AFTER_SECONDS = 300
+
+# Retry policy for one refresh cycle
+_MAX_ATTEMPTS = 8
+_BASE_DELAY_SECONDS = 0.1
+_MAX_DELAY_SECONDS = 5.0
+_JITTER_RATIO = 0.2
+
+_CLOSED_MESSAGE = "The token manager is closed."
+_REFRESH_FAILED_MESSAGE = (
+ "Could not refresh the ElastiCache IAM authentication token before the previous "
+ "token expired."
+)
+_REPLACEMENT_FAILED_MESSAGE = (
+ "Could not sign a replacement ElastiCache IAM authentication token."
+)
+
+# Module attributes so tests can substitute the clock, scheduler, jitter, and sleep.
+# Wall clock, not monotonic: the server judges the token by X-Amz-Date against its own
+# wall clock, and a monotonic clock stands still while the host is suspended.
+_now = time.time
+_random = random.random # jitter only, not security-sensitive
+
+
+def _wait(stop: threading.Event, seconds: float) -> None:
+ """Sleep between retries; returns early once ``stop`` is set by close()."""
+ stop.wait(seconds)
+
+
+class _ScheduledCall(Protocol):
+ def cancel(self) -> None: ...
+
+
+def _schedule(delay: float, callback: Callable[[], None]) -> _ScheduledCall:
+ """Run ``callback`` after ``delay`` seconds on a daemon thread."""
+ timer = threading.Timer(delay, callback)
+ timer.daemon = True
+ timer.name = "elasticache-iam-token-refresh"
+ timer.start()
+ return timer
+
+
+@dataclass(frozen=True)
+class _CachedToken:
+ token: str = field(repr=False) # bearer secret: keep it out of dumps of locals
+ issued_at: float
+ expires_at: float
+
+
+def _validate_refresh_after(value: object) -> float:
+ valid = (
+ isinstance(value, (int, float))
+ and not isinstance(value, bool)
+ and math.isfinite(value)
+ and 0 < value < _EFFECTIVE_LIFETIME_SECONDS
+ )
+ if not valid:
+ raise InvalidParameterError(
+ "Invalid value for parameter 'refresh_after': must be greater than 0 "
+ f"and less than {_EFFECTIVE_LIFETIME_SECONDS}."
+ )
+ return float(value)
+
+
+class ElastiCacheIAMAuthTokenManager:
+ """Cache an ElastiCache IAM auth token and refresh it in the background.
+
+ Takes the provider's arguments; configuration is validated at construction and
+ the first token is signed lazily. A replacement is signed ``refresh_after``
+ seconds after each token (default 300; must be below the 880s effective lifetime,
+ which is the 900s token lifetime minus a 20s serve margin) while callers keep
+ receiving the current one. ``on_token_changed`` is called synchronously
+ with each new token; its failures are logged and otherwise ignored.
+
+ ``refresh_token()`` signs a replacement immediately, for a client whose ``AUTH``
+ was rejected with a token this manager still considers valid.
+
+ Thread-safe: callers with no valid token share one signing cycle. Call
+ ``close()`` (or use as a context manager) when done; later calls raise
+ ``TokenRefreshError``.
+ """
+
+ def __init__( # noqa: PLR0913 # the provider's keyword-only arguments plus manager options
+ self,
+ *,
+ user_id: str,
+ region: str | None = None,
+ serverless_cache_name: str | None = None,
+ replication_group_id: str | None = None,
+ session: Session | None = None,
+ credentials: "Credentials | None" = None,
+ refresh_after: float = DEFAULT_REFRESH_AFTER_SECONDS,
+ on_token_changed: Callable[[str], None] | None = None,
+ ):
+ self._provider = ElastiCacheIAMAuthTokenProvider(
+ user_id=user_id,
+ region=region,
+ serverless_cache_name=serverless_cache_name,
+ replication_group_id=replication_group_id,
+ session=session,
+ credentials=credentials,
+ )
+ self._refresh_after = _validate_refresh_after(refresh_after)
+ self._on_token_changed = on_token_changed
+ # Guards the fields below; never held while signing or sleeping.
+ self._lock = threading.Lock()
+ self._cached: _CachedToken | None = None
+ self._closed = False
+ self._stop = threading.Event()
+ self._inflight: Future[str] | None = None # the signing cycle in progress
+ self._scheduled: _ScheduledCall | None = None
+ self._refresh_due_at = math.inf # in the past = missed timer, refresh now
+ self._refreshing = False
+
+ @property
+ def user_id(self) -> str:
+ return self._provider.user_id
+
+ def get_credentials(self) -> tuple[str, str]:
+ """Return ``(user_id, token)`` for clients that take a credentials callable."""
+ return self.user_id, self.get_token()
+
+ def get_token(self) -> str:
+ """Return a valid token, signing one only if the cache is empty or expired.
+
+ Blocks for the signing cycle (retries included) only when no valid token is
+ cached. Raises ``TokenRefreshError`` if the cached token expired before a
+ refresh replaced it, or after ``close()``; a first-token failure raises the
+ underlying error.
+ """
+ with self._lock:
+ self._ensure_open()
+ cached = self._cached
+ now = _now()
+ if cached is not None and now < cached.expires_at:
+ if now >= self._refresh_due_at:
+ self._start_background_refresh_locked(cached)
+ return cached.token
+ return self._refresh(reuse_unless=None)
+
+ def refresh_token(self) -> str:
+ """Sign a replacement token now and return it.
+
+ Use after the server rejected the current token (for example because the
+ signing credentials were revoked). Joins a signing cycle already in progress
+ instead of starting another. Raises ``TokenRefreshError`` if no replacement
+ could be signed, or after ``close()``.
+ """
+ with self._lock:
+ self._ensure_open()
+ current = self._cached
+ token = self._refresh(reuse_unless=current)
+ with self._lock:
+ replaced = self._cached is not None and self._cached is not current
+ if not replaced:
+ raise TokenRefreshError(_REPLACEMENT_FAILED_MESSAGE)
+ return token
+
+ def close(self) -> None:
+ """Stop background refresh and drop the cached token. Idempotent."""
+ with self._lock:
+ self._closed = True
+ self._cached = None
+ scheduled, self._scheduled = self._scheduled, None
+ self._stop.set()
+ if scheduled is not None:
+ scheduled.cancel()
+
+ def __enter__(self) -> "ElastiCacheIAMAuthTokenManager": # noqa: PYI034 # typing.Self is 3.11+; the package supports 3.10
+ return self
+
+ def __exit__(self, *exc_info: object) -> None:
+ self.close()
+
+ def _ensure_open(self) -> None:
+ if self._closed:
+ raise TokenRefreshError(_CLOSED_MESSAGE)
+
+ def _refresh(self, *, reuse_unless: "_CachedToken | None") -> str:
+ """Sign a new token, unless a valid one other than ``reuse_unless`` is cached.
+
+ ``reuse_unless`` is the token the caller wants replaced (``None`` to accept any
+ valid token). Joins the signing cycle already in progress, if any, and shares
+ its outcome.
+ """
+ with self._lock:
+ self._ensure_open()
+ cached = self._cached
+ if (
+ cached is not None
+ and cached is not reuse_unless
+ and _now() < cached.expires_at
+ ):
+ return cached.token
+ flight = self._inflight
+ owner = flight is None
+ if owner:
+ flight = self._inflight = Future()
+ if not owner:
+ return flight.result()
+ try:
+ token, installed = self._run_refresh_cycle()
+ except BaseException as error:
+ flight.set_exception(error)
+ raise
+ else:
+ flight.set_result(token)
+ finally:
+ with self._lock:
+ if self._inflight is flight:
+ self._inflight = None
+ # Only after the flight is closed: a refresh timer that fires now starts its own
+ # cycle instead of joining this one and skipping the reschedule, and a slow
+ # callback cannot hold the flight open.
+ if installed:
+ with self._lock:
+ if not self._closed and self._cached is not None:
+ self._schedule_locked(self._refresh_after, self._cached)
+ self._notify(token)
+ return token
+
+ def _run_refresh_cycle(self) -> tuple[str, bool]:
+ """Sign with retries; returns ``(token, newly_installed)``.
+
+ Every error is retried: on an instance role a transient IMDS failure surfaces
+ as "no credentials", so even ``ConfigurationError`` may be transient. On
+ failure: return a still-valid cached token and reschedule, raise the last
+ error if nothing is cached, or raise ``TokenRefreshError`` if it expired.
+ """
+ last_error: Exception | None = None
+ for attempt in range(1, _MAX_ATTEMPTS + 1):
+ self._raise_if_closed(last_error)
+ try:
+ issued_at = _now()
+ token = self._provider.get_token()
+ except Exception as error: # noqa: BLE001 # see docstring
+ last_error = error
+ if attempt < _MAX_ATTEMPTS:
+ _wait(self._stop, self._backoff_delay(attempt))
+ continue
+ with self._lock:
+ self._ensure_open()
+ self._install_locked(token, issued_at)
+ return token, True
+
+ remaining = 0.0
+ with self._lock:
+ self._raise_if_closed(last_error)
+ cached = self._cached
+ if cached is not None and _now() < cached.expires_at:
+ self._schedule_locked(_MAX_DELAY_SECONDS, cached)
+ remaining = cached.expires_at - _now()
+ if cached is not None and remaining > 0:
+ _logger.warning(
+ "Could not refresh the ElastiCache IAM auth token after %d attempts "
+ "(%s: %s); serving the current token for another %.0fs and retrying "
+ "in %.0fs.",
+ _MAX_ATTEMPTS,
+ type(last_error).__name__,
+ last_error,
+ remaining,
+ _MAX_DELAY_SECONDS,
+ )
+ # Re-check: the token may have expired while the lock was released, and a
+ # waiter that joined because its token expired must not get it back.
+ with self._lock:
+ self._raise_if_closed(last_error)
+ if _now() < cached.expires_at:
+ return cached.token, False
+ if cached is None and last_error is not None:
+ raise last_error
+ raise TokenRefreshError(_REFRESH_FAILED_MESSAGE) from last_error
+
+ def _notify(self, token: str) -> None:
+ if self._on_token_changed is None:
+ return
+ try:
+ self._on_token_changed(token)
+ except Exception as error: # noqa: BLE001 # a callback failure must not discard the token
+ # Type only: the callback received the token, so its message might too.
+ _logger.warning("on_token_changed callback raised %s", type(error).__name__)
+
+ def _raise_if_closed(self, cause: Exception | None) -> None:
+ if self._closed:
+ raise TokenRefreshError(_CLOSED_MESSAGE) from cause
+
+ @staticmethod
+ def _backoff_delay(failed_attempt: int) -> float:
+ delay = min(_BASE_DELAY_SECONDS * 2 ** (failed_attempt - 1), _MAX_DELAY_SECONDS)
+ jitter = 1 - _JITTER_RATIO + 2 * _JITTER_RATIO * _random()
+ return delay * jitter
+
+ def _install_locked(self, token: str, issued_at: float) -> None:
+ """Cache the token. Its refresh timer is started by ``_refresh`` once the
+ flight is closed; until then the due time is set so nobody treats the gap as
+ a missed timer."""
+ self._cached = _CachedToken(
+ token=token,
+ issued_at=issued_at,
+ expires_at=issued_at + _EFFECTIVE_LIFETIME_SECONDS,
+ )
+ self._refresh_due_at = _now() + self._refresh_after
+
+ def _schedule_locked(self, delay: float, due: _CachedToken) -> None:
+ """Schedule a background refresh of ``due``; on failure, leave it to get_token()."""
+ previous, self._scheduled = self._scheduled, None
+ if previous is not None:
+ previous.cancel()
+ try:
+ self._scheduled = _schedule(delay, lambda: self._background_refresh(due))
+ except Exception: # noqa: BLE001 # e.g. RuntimeError: can't start new thread
+ self._refresh_due_at = -math.inf
+ self._refreshing = False
+ return
+ self._refresh_due_at = _now() + delay
+
+ def _start_background_refresh_locked(self, due: _CachedToken) -> None:
+ if not self._refreshing:
+ self._refreshing = True
+ self._schedule_locked(0, due)
+
+ def _background_refresh(self, due: _CachedToken) -> None:
+ """Replace ``due`` with a fresh token. Runs on the scheduler's thread."""
+ with self._lock:
+ if self._closed:
+ self._refreshing = False
+ return
+ self._refreshing = True
+ try:
+ # A cycle that fails while the token is valid logs, reschedules, and
+ # returns. It only raises once the token has expired; the next get_token()
+ # surfaces that to a caller, so nothing is lost by not re-raising here.
+ self._refresh(reuse_unless=due)
+ except Exception as error: # noqa: BLE001 # a timer thread has nobody to raise to
+ _logger.warning(
+ "Background refresh of the ElastiCache IAM auth token failed and the "
+ "cached token has expired (%s: %s).",
+ type(error).__name__,
+ error,
+ )
+ finally:
+ with self._lock:
+ self._refreshing = False
diff --git a/python/tests/conftest.py b/python/tests/conftest.py
new file mode 100644
index 0000000..f86b662
--- /dev/null
+++ b/python/tests/conftest.py
@@ -0,0 +1,23 @@
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+import pytest
+from helpers import fake_session
+
+
+@pytest.fixture
+def mock_session():
+ return fake_session()
+
+
+@pytest.fixture(autouse=True)
+def no_region_env(monkeypatch):
+ """Sever the region environment variables for every test.
+
+ Autouse because AWS_REGION is read from the environment directly, so without this
+ the suite's result would depend on the developer's shell: a region exported
+ locally would mask the session fallback and quietly pass a broken build. Tests
+ that want the env var set it explicitly with monkeypatch.setenv.
+ """
+ monkeypatch.delenv("AWS_REGION", raising=False)
+ monkeypatch.delenv("AWS_DEFAULT_REGION", raising=False)
diff --git a/python/tests/helpers.py b/python/tests/helpers.py
new file mode 100644
index 0000000..12b3f28
--- /dev/null
+++ b/python/tests/helpers.py
@@ -0,0 +1,48 @@
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+"""Test doubles shared by the token generator and token manager suites."""
+
+from unittest.mock import MagicMock
+
+from botocore.credentials import Credentials
+from botocore.session import Session
+
+CACHE = "my-cache"
+USER = "testuser"
+REGION = "us-east-1"
+SESSION_REGION = "eu-west-1"
+
+DEFAULT_CREDENTIALS = Credentials("FRAIDA1FODNN7EXAMPLE", "secret", "token")
+ROTATED_CREDENTIALS = Credentials("AKIAI44QH8DHBEXAMPLE", "other-secret", "token2")
+
+_UNSET = object()
+
+
+def fake_session(credentials=_UNSET, region=_UNSET):
+ """A botocore session stand-in that vends fixed credentials and a region.
+
+ Every test signs with an injected session, so the suite never touches the real
+ AWS credential chain: no environment, config file, or IMDS lookup can leak in.
+ Pass ``credentials=None`` to simulate an empty credential chain, or
+ ``region=None`` to simulate a session with no region configured.
+
+ The region is stubbed explicitly rather than left to MagicMock's auto-attribute,
+ because an auto-created mock is truthy and would let a broken fallback look like
+ it resolved a region.
+ """
+ if credentials is _UNSET:
+ credentials = DEFAULT_CREDENTIALS
+ if region is _UNSET:
+ region = SESSION_REGION
+ session = MagicMock(spec=Session)
+ if credentials is None:
+ session.get_credentials.return_value = None
+ else:
+ session.get_credentials.return_value = MagicMock(
+ get_frozen_credentials=MagicMock(return_value=credentials)
+ )
+ session.get_config_variable.side_effect = lambda name: (
+ region if name == "region" else None
+ )
+ return session
diff --git a/python/tests/test_token_generator.py b/python/tests/test_token_generator.py
index 0dd9d04..41b007e 100644
--- a/python/tests/test_token_generator.py
+++ b/python/tests/test_token_generator.py
@@ -5,8 +5,15 @@
from unittest.mock import MagicMock, patch
import pytest
-from botocore.credentials import Credentials
-from botocore.session import Session
+from botocore.credentials import Credentials, RefreshableCredentials
+from helpers import (
+ CACHE,
+ REGION,
+ ROTATED_CREDENTIALS,
+ SESSION_REGION,
+ USER,
+ fake_session,
+)
from developer_toolkit_elasticache import (
ElastiCacheIAMAuthTokenProvider,
@@ -17,67 +24,6 @@
InvalidParameterError,
)
-CACHE = "my-cache"
-USER = "testuser"
-REGION = "us-east-1"
-# Deliberately different from REGION so a test can tell an explicitly-passed region
-# apart from one that fell back to the session.
-SESSION_REGION = "eu-west-1"
-
-_DEFAULT_CREDENTIALS = Credentials("FRAIDA1FODNN7EXAMPLE", "secret", "token")
-_ROTATED_CREDENTIALS = Credentials("AKIAI44QH8DHBEXAMPLE", "other-secret", "token2")
-
-# Sentinel so callers can ask for "no credentials at all" (None) and still be
-# distinguishable from "just give me the default set".
-_UNSET = object()
-
-
-def _fake_session(credentials=_UNSET, region=_UNSET):
- """A botocore session stand-in that vends fixed credentials and a region.
-
- Every test signs with an injected session, so the suite never touches the real
- AWS credential chain: no environment, config file, or IMDS lookup can leak in.
- Pass ``credentials=None`` to simulate an empty credential chain, or
- ``region=None`` to simulate a session with no region configured.
-
- The region is stubbed explicitly rather than left to MagicMock's auto-attribute,
- because an auto-created mock is truthy and would let a broken fallback look like
- it resolved a region.
- """
- if credentials is _UNSET:
- credentials = _DEFAULT_CREDENTIALS
- if region is _UNSET:
- region = SESSION_REGION
- session = MagicMock(spec=Session)
- if credentials is None:
- session.get_credentials.return_value = None
- else:
- session.get_credentials.return_value = MagicMock(
- get_frozen_credentials=MagicMock(return_value=credentials)
- )
- session.get_config_variable.side_effect = lambda name: (
- region if name == "region" else None
- )
- return session
-
-
-@pytest.fixture
-def mock_session():
- return _fake_session()
-
-
-@pytest.fixture(autouse=True)
-def no_region_env(monkeypatch):
- """Sever the region environment variables for every test.
-
- Autouse because AWS_REGION is read from the environment directly, so without this
- the suite's result would depend on the developer's shell: a region exported
- locally would mask the session fallback and quietly pass a broken build. Tests
- that want the env var set it explicitly with monkeypatch.setenv.
- """
- monkeypatch.delenv("AWS_REGION", raising=False)
- monkeypatch.delenv("AWS_DEFAULT_REGION", raising=False)
-
def _assert_valid_token(token, *, serverless=True):
assert token is not None
@@ -167,7 +113,7 @@ def test_rotated_credentials_change_the_signature(mock_session):
first = auth.get_token()
mock_session.get_credentials.return_value = MagicMock(
- get_frozen_credentials=MagicMock(return_value=_ROTATED_CREDENTIALS)
+ get_frozen_credentials=MagicMock(return_value=ROTATED_CREDENTIALS)
)
assert auth.get_token() != first
@@ -189,7 +135,7 @@ def test_empty_credential_chain_raises():
serverless_cache_name=CACHE,
user_id=USER,
region=REGION,
- session=_fake_session(credentials=None),
+ session=fake_session(credentials=None),
)
with pytest.raises(ConfigurationError, match="credentials"):
auth.get_token()
@@ -398,7 +344,7 @@ def test_region_falls_back_to_the_session(mock_session):
def test_explicit_region_overrides_the_session():
"""An explicitly passed region wins over the session's configured one."""
- session = _fake_session(region=SESSION_REGION)
+ session = fake_session(region=SESSION_REGION)
token = generate_iam_auth_token(
serverless_cache_name=CACHE, user_id=USER, region=REGION, session=session
)
@@ -413,7 +359,7 @@ def test_aws_region_env_var_is_honoured(mock_session, monkeypatch):
is set only there would otherwise have a working CLI and a failing toolkit.
"""
monkeypatch.setenv("AWS_REGION", ENV_REGION)
- session = _fake_session(region=None)
+ session = fake_session(region=None)
token = generate_iam_auth_token(
serverless_cache_name=CACHE, user_id=USER, session=session
)
@@ -423,7 +369,7 @@ def test_aws_region_env_var_is_honoured(mock_session, monkeypatch):
def test_aws_region_takes_priority_over_the_session(monkeypatch):
"""Environment beats config file, matching how botocore ranks its own sources."""
monkeypatch.setenv("AWS_REGION", ENV_REGION)
- session = _fake_session(region=SESSION_REGION)
+ session = fake_session(region=SESSION_REGION)
token = generate_iam_auth_token(
serverless_cache_name=CACHE, user_id=USER, session=session
)
@@ -438,7 +384,7 @@ def test_explicit_region_overrides_aws_region_env_var(monkeypatch):
serverless_cache_name=CACHE,
user_id=USER,
region=REGION,
- session=_fake_session(region=SESSION_REGION),
+ session=fake_session(region=SESSION_REGION),
)
assert f"%2F{REGION}%2F" in token
assert ENV_REGION not in token
@@ -446,7 +392,7 @@ def test_explicit_region_overrides_aws_region_env_var(monkeypatch):
def test_missing_region_raises():
"""No region anywhere is a user-fixable configuration problem."""
- session = _fake_session(region=None)
+ session = fake_session(region=None)
with pytest.raises(ConfigurationError, match="region"):
generate_iam_auth_token(
serverless_cache_name=CACHE, user_id=USER, session=session
@@ -455,7 +401,7 @@ def test_missing_region_raises():
def test_missing_region_error_names_every_source():
"""The message must name each place a region can come from."""
- session = _fake_session(region=None)
+ session = fake_session(region=None)
with pytest.raises(ConfigurationError, match="AWS_REGION"):
generate_iam_auth_token(
serverless_cache_name=CACHE, user_id=USER, session=session
@@ -475,7 +421,7 @@ def test_class_region_falls_back_to_the_session(mock_session):
def test_class_missing_region_fails_at_construction():
"""A missing region surfaces immediately, not at the first connection attempt."""
- session = _fake_session(region=None)
+ session = fake_session(region=None)
with pytest.raises(ConfigurationError, match="region"):
ElastiCacheIAMAuthTokenProvider(
serverless_cache_name=CACHE, user_id=USER, session=session
@@ -505,7 +451,7 @@ def test_class_missing_region_fails_at_construction():
def _kat_session():
- return _fake_session(credentials=_KAT_CREDENTIALS, region=_KAT_REGION)
+ return fake_session(credentials=_KAT_CREDENTIALS, region=_KAT_REGION)
def _generate_at_fixed_time(**kwargs):
@@ -555,3 +501,70 @@ def test_known_answer_signatures_differ_by_resource_type():
serverless = _generate_at_fixed_time(serverless_cache_name=_KAT_CACHE)
node_based = _generate_at_fixed_time(replication_group_id=_KAT_CACHE)
assert serverless != node_based
+
+
+class TestSuppliedCredentials:
+ """``credentials`` lets a caller sign with an object it already holds, with no session."""
+
+ def test_signs_with_the_supplied_credentials(self):
+ token = generate_iam_auth_token(
+ serverless_cache_name="my-cache",
+ user_id="default.iam-user",
+ region="us-east-1",
+ credentials=Credentials("AKIASUPPLIED", "secret"),
+ )
+
+ assert "AKIASUPPLIED" in token
+
+ def test_supplied_credentials_win_over_a_session(self):
+ """Both are accepted; the explicit object is the one that signs."""
+ token = generate_iam_auth_token(
+ serverless_cache_name="my-cache",
+ user_id="default.iam-user",
+ region="us-east-1",
+ session=fake_session(),
+ credentials=Credentials("AKIASUPPLIED", "secret"),
+ )
+
+ assert "AKIASUPPLIED" in token
+
+ def test_provider_signs_with_the_supplied_credentials(self):
+ provider = ElastiCacheIAMAuthTokenProvider(
+ serverless_cache_name="my-cache",
+ user_id="default.iam-user",
+ region="us-east-1",
+ credentials=Credentials("AKIASUPPLIED", "secret"),
+ )
+
+ assert "AKIASUPPLIED" in provider.get_token()
+
+ def test_rereads_supplied_credentials_so_rotation_is_picked_up(self):
+ calls = {"n": 0}
+
+ def refresh():
+ calls["n"] += 1
+ return {
+ "access_key": f"AKIAROTATED{calls['n']}",
+ "secret_key": "secret",
+ "token": "token",
+ "expiry_time": (
+ datetime.datetime.now(datetime.timezone.utc)
+ + datetime.timedelta(minutes=30)
+ ).isoformat(),
+ }
+
+ credentials = RefreshableCredentials.create_from_metadata(
+ refresh(), refresh, "assume-role"
+ )
+ provider = ElastiCacheIAMAuthTokenProvider(
+ serverless_cache_name="my-cache",
+ user_id="default.iam-user",
+ region="us-east-1",
+ credentials=credentials,
+ )
+
+ assert "AKIAROTATED1" in provider.get_token()
+ credentials._expiry_time = datetime.datetime.now(
+ datetime.timezone.utc
+ ) - datetime.timedelta(minutes=1)
+ assert "AKIAROTATED2" in provider.get_token()
diff --git a/python/tests/test_token_manager.py b/python/tests/test_token_manager.py
new file mode 100644
index 0000000..4911256
--- /dev/null
+++ b/python/tests/test_token_manager.py
@@ -0,0 +1,976 @@
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+
+import threading
+import time
+from concurrent.futures import Future
+
+import pytest
+from helpers import CACHE, REGION, ROTATED_CREDENTIALS, USER, fake_session
+
+import developer_toolkit_elasticache as package
+from developer_toolkit_elasticache import token_manager
+from developer_toolkit_elasticache.errors import (
+ ConfigurationError,
+ InvalidParameterError,
+ TokenRefreshError,
+ ToolkitInputError,
+)
+from developer_toolkit_elasticache.token_manager import (
+ DEFAULT_REFRESH_AFTER_SECONDS,
+ ElastiCacheIAMAuthTokenManager,
+)
+
+TOKEN_TTL = 900
+SERVE_MARGIN = 20
+EFFECTIVE_LIFETIME = TOKEN_TTL - SERVE_MARGIN # 880: served this long after signing
+MAX_ATTEMPTS = 8
+MAX_DELAY = 5.0
+
+# Captured before any fixture patches them, for the tests of the real implementations.
+REAL_SCHEDULE = token_manager._schedule
+REAL_WAIT = token_manager._wait
+
+
+class FakeClock:
+ """A wall clock the test advances by hand."""
+
+ def __init__(self, start=1_000.0):
+ self.now = start
+
+ def __call__(self):
+ return self.now
+
+ def advance(self, seconds):
+ self.now += seconds
+
+
+@pytest.fixture
+def clock(monkeypatch):
+ fake = FakeClock()
+ monkeypatch.setattr(token_manager, "_now", fake)
+ return fake
+
+
+class FakeScheduledCall:
+ def __init__(self, delay, callback):
+ self.delay = delay
+ self.callback = callback
+ self.cancelled = False
+
+ def cancel(self):
+ self.cancelled = True
+
+
+class FakeScheduler:
+ """Captures scheduled callbacks so a test can fire them deterministically."""
+
+ def __init__(self):
+ self.calls = []
+ self.fail = False
+
+ def __call__(self, delay, callback):
+ if self.fail:
+ raise RuntimeError("can't start new thread")
+ call = FakeScheduledCall(delay, callback)
+ self.calls.append(call)
+ return call
+
+ @property
+ def pending(self):
+ return [call for call in self.calls if not call.cancelled]
+
+ def fire_pending(self):
+ """Run every live callback once, on this thread, as the timer would."""
+ for call in self.pending:
+ call.cancel()
+ call.callback()
+
+
+@pytest.fixture(autouse=True)
+def scheduler(monkeypatch):
+ fake = FakeScheduler()
+ monkeypatch.setattr(token_manager, "_schedule", fake)
+ return fake
+
+
+class FakeSleep:
+ """Records retry delays instead of sleeping.
+
+ The real ``_wait`` honours close() through the stop event; that contract is
+ covered separately by ``test_real_wait_returns_early_when_stopped``.
+ """
+
+ def __init__(self):
+ self.delays = []
+ self.on_sleep = None
+
+ def __call__(self, stop, seconds):
+ self.delays.append(round(seconds, 6))
+ if self.on_sleep is not None:
+ self.on_sleep()
+
+
+@pytest.fixture(autouse=True)
+def sleep(monkeypatch):
+ fake = FakeSleep()
+ monkeypatch.setattr(token_manager, "_wait", fake)
+ # Midpoint jitter (factor 1.0) so recorded delays equal the nominal backoff.
+ monkeypatch.setattr(token_manager, "_random", lambda: 0.5)
+ return fake
+
+
+def _failing_then_ok(session, failures, error=None):
+ """Make credential resolution raise ``failures`` times, then succeed."""
+ credentials = session.get_credentials.return_value
+ calls = {"n": 0}
+
+ def side_effect():
+ calls["n"] += 1
+ if calls["n"] <= failures:
+ raise error or RuntimeError(f"transient failure {calls['n']}")
+ return credentials
+
+ session.get_credentials.side_effect = side_effect
+
+
+def _manager(session, **kwargs):
+ kwargs.setdefault("serverless_cache_name", CACHE)
+ kwargs.setdefault("user_id", USER)
+ kwargs.setdefault("region", REGION)
+ return ElastiCacheIAMAuthTokenManager(session=session, **kwargs)
+
+
+def _run_concurrently(count, target, *, after_start=None):
+ """Start ``count`` daemon threads that all enter ``target`` at once.
+
+ ``after_start`` runs on the test thread once the workers are going, e.g. to release
+ a flight they are waiting on. A deadlock fails the liveness assertion instead of
+ hanging pytest, because the workers are daemons.
+ """
+ results = []
+ results_lock = threading.Lock()
+ ready = threading.Barrier(count)
+
+ def worker():
+ ready.wait(timeout=5)
+ try:
+ outcome = target()
+ except Exception as error: # noqa: BLE001 # collected for the assertions
+ outcome = error
+ with results_lock:
+ results.append(outcome)
+
+ threads = [threading.Thread(target=worker, daemon=True) for _ in range(count)]
+ for thread in threads:
+ thread.start()
+ if after_start is not None:
+ after_start()
+ for thread in threads:
+ thread.join(timeout=5)
+ assert not thread.is_alive(), "worker did not finish: deadlock?"
+ assert len(results) == count
+ return results
+
+
+def _run_bounded(target, timeout=5):
+ """Run ``target`` on a daemon thread; fail (don't hang) if it does not finish."""
+ box = {}
+
+ def run():
+ try:
+ box["result"] = target()
+ except Exception as error: # noqa: BLE001 # re-raised on the test thread
+ box["error"] = error
+
+ thread = threading.Thread(target=run, daemon=True)
+ thread.start()
+ thread.join(timeout=timeout)
+ assert not thread.is_alive(), "target did not finish: deadlock?"
+ if "error" in box:
+ raise box["error"]
+ return box["result"]
+
+
+class JoinCountingFuture(Future):
+ """A Future that counts callers entering result(), so a test can wait until
+ every waiter has actually joined the flight before releasing the owner."""
+
+ joins = 0
+ joins_changed = threading.Condition()
+
+ def result(self, timeout=None):
+ with JoinCountingFuture.joins_changed:
+ JoinCountingFuture.joins += 1
+ JoinCountingFuture.joins_changed.notify_all()
+ return super().result(timeout)
+
+ @classmethod
+ def reset(cls):
+ with cls.joins_changed:
+ cls.joins = 0
+
+ @classmethod
+ def wait_for_joins(cls, count, timeout=5):
+ with cls.joins_changed:
+ assert cls.joins_changed.wait_for(lambda: cls.joins >= count, timeout), (
+ f"only {cls.joins} of {count} waiters joined the flight"
+ )
+
+
+@pytest.fixture
+def join_counting_future(monkeypatch):
+ JoinCountingFuture.reset()
+ monkeypatch.setattr(token_manager, "Future", JoinCountingFuture)
+ return JoinCountingFuture
+
+
+# --- errors and exports ------------------------------------------------------------
+
+
+def test_token_refresh_error_is_not_an_input_error():
+ assert issubclass(TokenRefreshError, Exception)
+ assert not issubclass(TokenRefreshError, ToolkitInputError)
+
+
+def test_package_root_exports_the_manager_api():
+ assert package.ElastiCacheIAMAuthTokenManager is ElastiCacheIAMAuthTokenManager
+ assert package.TokenRefreshError is TokenRefreshError
+ assert package.DEFAULT_REFRESH_AFTER_SECONDS == DEFAULT_REFRESH_AFTER_SECONDS
+ assert "ElastiCacheIAMAuthTokenManager" in package.__all__
+ assert "TokenRefreshError" in package.__all__
+
+
+# --- caching -----------------------------------------------------------------------
+
+
+def test_signs_lazily_and_caches_the_token(clock, mock_session):
+ manager = _manager(mock_session)
+ assert mock_session.get_credentials.call_count == 0
+
+ first = manager.get_token()
+ second = manager.get_token()
+
+ assert first == second
+ assert f"{CACHE}/?" in first
+ assert mock_session.get_credentials.call_count == 1
+
+
+def test_serves_the_cached_token_until_the_serve_margin(clock, mock_session):
+ # Stop 20s before the real 900s expiry to absorb clock skew and the AUTH round trip.
+ manager = _manager(mock_session)
+ manager.get_token()
+
+ clock.advance(EFFECTIVE_LIFETIME - 1)
+ manager.get_token()
+ assert mock_session.get_credentials.call_count == 1
+
+ clock.advance(1)
+ manager.get_token()
+ assert mock_session.get_credentials.call_count == 2
+
+
+def test_uses_the_wall_clock_for_expiry():
+ # The server judges the token by X-Amz-Date against its wall clock; a monotonic
+ # clock would stand still through a host suspend and overstate validity.
+ assert token_manager._now is time.time
+
+
+def test_get_credentials_returns_the_normalized_user_id_and_cached_token(
+ clock, mock_session
+):
+ manager = _manager(mock_session, user_id="MixedCaseUser")
+
+ user_id, token = manager.get_credentials()
+
+ assert user_id == "mixedcaseuser"
+ assert token == manager.get_token()
+ assert manager.user_id == "mixedcaseuser"
+
+
+def test_cached_token_repr_does_not_contain_the_token(clock, mock_session):
+ manager = _manager(mock_session)
+ token = manager.get_token()
+
+ assert "X-Amz-Signature" not in repr(manager._cached)
+ assert token not in repr(manager._cached)
+
+
+def test_first_signing_failure_is_the_underlying_error(clock, sleep):
+ manager = _manager(fake_session(credentials=None))
+
+ with pytest.raises(ConfigurationError):
+ manager.get_token()
+ assert len(sleep.delays) == MAX_ATTEMPTS - 1
+
+
+def test_validates_parameters_at_construction(clock, mock_session):
+ with pytest.raises(InvalidParameterError):
+ _manager(mock_session, serverless_cache_name="bad_name!")
+ with pytest.raises(ConfigurationError):
+ _manager(fake_session(region=None), region=None)
+
+
+# --- single flight -----------------------------------------------------------------
+
+
+def test_concurrent_callers_share_one_signing(clock, join_counting_future):
+ session = fake_session()
+ credentials = session.get_credentials.return_value
+ release_owner = threading.Event()
+
+ def blocked_get_credentials():
+ # The owner holds the flight open here until every waiter has joined it.
+ assert release_owner.wait(timeout=5)
+ return credentials
+
+ session.get_credentials.side_effect = blocked_get_credentials
+ manager = _manager(session)
+
+ def release_once_all_joined():
+ join_counting_future.wait_for_joins(4) # 5 callers: 1 owner + 4 waiters
+ release_owner.set()
+
+ tokens = _run_concurrently(5, manager.get_token, after_start=release_once_all_joined)
+
+ assert all(isinstance(token, str) for token in tokens)
+ assert len(set(tokens)) == 1
+ assert session.get_credentials.call_count == 1
+
+
+def test_concurrent_callers_share_one_failure(clock, sleep, join_counting_future):
+ # A failed cycle is reported to every waiter, rather than each waiter running its
+ # own eight attempts in turn.
+ session = fake_session()
+ _failing_then_ok(session, failures=1_000)
+ release_owner = threading.Event()
+ # The owner's first retry sleep holds the flight open until the waiters joined.
+ sleep.on_sleep = lambda: release_owner.wait(timeout=5)
+ manager = _manager(session)
+
+ def release_once_all_joined():
+ join_counting_future.wait_for_joins(3) # 4 callers: 1 owner + 3 waiters
+ release_owner.set()
+
+ outcomes = _run_concurrently(
+ 4, manager.get_token, after_start=release_once_all_joined
+ )
+
+ assert all(isinstance(outcome, RuntimeError) for outcome in outcomes)
+ assert len({id(outcome) for outcome in outcomes}) == 1
+ assert session.get_credentials.call_count == MAX_ATTEMPTS
+
+
+# --- refresh_after -----------------------------------------------------------------
+
+
+def test_default_refresh_after_is_five_minutes():
+ assert DEFAULT_REFRESH_AFTER_SECONDS == 300
+
+
+@pytest.mark.parametrize(
+ "refresh_after",
+ [
+ 0,
+ -1,
+ EFFECTIVE_LIFETIME,
+ EFFECTIVE_LIFETIME + 1,
+ TOKEN_TTL,
+ "300",
+ True,
+ float("nan"),
+ ],
+)
+def test_rejects_invalid_refresh_after(clock, mock_session, refresh_after):
+ with pytest.raises(InvalidParameterError) as excinfo:
+ _manager(mock_session, refresh_after=refresh_after)
+ assert "refresh_after" in str(excinfo.value)
+
+
+@pytest.mark.parametrize("refresh_after", [0.001, 1, EFFECTIVE_LIFETIME - 0.001])
+def test_accepts_refresh_after_within_the_token_lifetime(
+ clock, scheduler, mock_session, refresh_after
+):
+ manager = _manager(mock_session, refresh_after=refresh_after)
+ manager.get_token()
+
+ assert [call.delay for call in scheduler.pending] == [refresh_after]
+
+
+# --- background refresh ------------------------------------------------------------
+
+
+def test_schedules_a_background_refresh_after_the_first_token(
+ clock, scheduler, mock_session
+):
+ manager = _manager(mock_session)
+ assert scheduler.pending == []
+
+ manager.get_token()
+
+ assert [call.delay for call in scheduler.pending] == [DEFAULT_REFRESH_AFTER_SECONDS]
+
+
+def test_background_refresh_installs_rotated_credentials(clock, scheduler):
+ session = fake_session()
+ manager = _manager(session)
+ first = manager.get_token()
+ session.get_credentials.return_value.get_frozen_credentials.return_value = (
+ ROTATED_CREDENTIALS
+ )
+
+ clock.advance(DEFAULT_REFRESH_AFTER_SECONDS)
+ scheduler.fire_pending()
+
+ second = manager.get_token()
+ assert second != first
+ assert "AKIAI44QH8DHBEXAMPLE" in second
+ assert session.get_credentials.call_count == 2
+ # The new token gets its own refresh timer.
+ assert [call.delay for call in scheduler.pending] == [DEFAULT_REFRESH_AFTER_SECONDS]
+
+
+def test_get_token_past_the_refresh_point_does_not_block(clock, scheduler):
+ # Timer never fired (e.g. the process was suspended): the caller still gets the
+ # current token immediately, and a refresh is kicked off in the background.
+ session = fake_session()
+ manager = _manager(session)
+ first = manager.get_token()
+
+ clock.advance(DEFAULT_REFRESH_AFTER_SECONDS + 1)
+ assert manager.get_token() == first
+ assert session.get_credentials.call_count == 1
+ assert [call.delay for call in scheduler.pending] == [0]
+
+ scheduler.fire_pending()
+ assert session.get_credentials.call_count == 2
+
+
+def test_only_one_background_refresh_is_started(clock, scheduler, mock_session):
+ manager = _manager(mock_session)
+ manager.get_token()
+ clock.advance(DEFAULT_REFRESH_AFTER_SECONDS + 1)
+
+ manager.get_token()
+ manager.get_token()
+
+ assert len(scheduler.pending) == 1
+
+
+def test_missed_timer_path_works_again_after_a_refresh_completes(
+ clock, scheduler, mock_session
+):
+ manager = _manager(mock_session)
+ manager.get_token()
+ clock.advance(DEFAULT_REFRESH_AFTER_SECONDS + 1)
+ manager.get_token()
+ scheduler.fire_pending() # first missed-timer refresh completes
+ assert mock_session.get_credentials.call_count == 2
+
+ clock.advance(DEFAULT_REFRESH_AFTER_SECONDS + 1)
+ manager.get_token()
+ scheduler.fire_pending()
+
+ assert mock_session.get_credentials.call_count == 3
+
+
+def test_stale_background_refresh_skips_signing_when_a_newer_token_exists(
+ clock, scheduler, mock_session
+):
+ manager = _manager(mock_session)
+ manager.get_token()
+ (stale_timer,) = scheduler.pending
+ clock.advance(EFFECTIVE_LIFETIME) # expired: the next call signs synchronously ...
+ manager.get_token()
+ assert mock_session.get_credentials.call_count == 2
+
+ stale_timer.callback() # ... so the old timer, firing late, has nothing to do
+
+ assert mock_session.get_credentials.call_count == 2
+
+
+def test_scheduling_failure_does_not_lose_the_token(clock, scheduler, mock_session):
+ scheduler.fail = True
+ manager = _manager(mock_session)
+
+ token = manager.get_token() # signed, installed, timer could not be started
+
+ assert f"{CACHE}/?" in token
+ assert manager.get_token() == token
+ # The due time is left in the past so the next call re-attempts scheduling.
+ scheduler.fail = False
+ manager.get_token()
+ assert [call.delay for call in scheduler.pending] == [0]
+
+
+def test_default_scheduler_uses_a_daemon_timer():
+ timer = REAL_SCHEDULE(60, lambda: None)
+ try:
+ assert isinstance(timer, threading.Timer)
+ assert timer.daemon
+ finally:
+ timer.cancel()
+
+
+# --- retry and failure semantics ---------------------------------------------------
+
+
+def test_retries_transient_failures_with_exponential_backoff(clock, sleep):
+ session = fake_session()
+ _failing_then_ok(session, failures=3)
+ manager = _manager(session)
+
+ token = manager.get_token()
+
+ assert f"{CACHE}/?" in token
+ assert session.get_credentials.call_count == 4
+ assert sleep.delays == [0.1, 0.2, 0.4]
+
+
+def test_backoff_is_capped(clock, sleep):
+ session = fake_session()
+ _failing_then_ok(session, failures=MAX_ATTEMPTS - 1)
+ manager = _manager(session)
+
+ manager.get_token()
+
+ assert session.get_credentials.call_count == MAX_ATTEMPTS
+ assert sleep.delays == [0.1, 0.2, 0.4, 0.8, 1.6, 3.2, MAX_DELAY]
+
+
+# random() never returns 1.0, so 1.0 here just pins the formula's upper bound (+20%).
+@pytest.mark.parametrize(("draw", "expected"), [(0.0, 0.08), (1.0, 0.12)])
+def test_backoff_is_jittered(clock, sleep, monkeypatch, draw, expected):
+ monkeypatch.setattr(token_manager, "_random", lambda: draw)
+ session = fake_session()
+ _failing_then_ok(session, failures=1)
+ manager = _manager(session)
+
+ manager.get_token()
+
+ assert sleep.delays == [expected]
+
+
+def test_first_token_failure_after_all_attempts_is_the_underlying_error(clock, sleep):
+ session = fake_session()
+ _failing_then_ok(session, failures=MAX_ATTEMPTS + 1)
+ manager = _manager(session)
+
+ with pytest.raises(RuntimeError, match="transient failure 8"):
+ manager.get_token()
+ assert session.get_credentials.call_count == MAX_ATTEMPTS
+ assert len(sleep.delays) == MAX_ATTEMPTS - 1
+
+
+def test_configuration_errors_are_retried_too(clock, sleep):
+ # On an instance role a transient IMDS failure surfaces as "no credentials"
+ # (botocore returns None), so ConfigurationError gets the same retries.
+ session = fake_session()
+ calls = {"n": 0}
+ credentials = session.get_credentials.return_value
+
+ def flaky_chain():
+ calls["n"] += 1
+ return None if calls["n"] <= 2 else credentials
+
+ session.get_credentials.side_effect = flaky_chain
+ manager = _manager(session)
+
+ token = manager.get_token()
+
+ assert f"{CACHE}/?" in token
+ assert sleep.delays == [0.1, 0.2]
+
+
+def test_serves_the_valid_token_while_refresh_keeps_failing(clock, scheduler, sleep):
+ session = fake_session()
+ manager = _manager(session)
+ first = manager.get_token()
+ _failing_then_ok(session, failures=MAX_ATTEMPTS + 1)
+
+ clock.advance(DEFAULT_REFRESH_AFTER_SECONDS)
+ scheduler.fire_pending()
+
+ assert manager.get_token() == first
+ assert len(sleep.delays) == MAX_ATTEMPTS - 1
+ # Another cycle is scheduled after the maximum backoff, not at refresh_after.
+ assert [call.delay for call in scheduler.pending] == [MAX_DELAY]
+
+
+def test_configuration_error_in_the_background_still_reschedules(
+ clock, scheduler, sleep, mock_session
+):
+ # The cycle must end normally: keep the valid token, reschedule, and never let
+ # get_token() spawn a refresh per call.
+ manager = _manager(mock_session)
+ first = manager.get_token()
+ mock_session.get_credentials.return_value = None # credential chain went empty
+
+ clock.advance(DEFAULT_REFRESH_AFTER_SECONDS)
+ scheduler.fire_pending()
+
+ assert len(sleep.delays) == MAX_ATTEMPTS - 1
+ assert [call.delay for call in scheduler.pending] == [MAX_DELAY]
+ for _ in range(3):
+ assert manager.get_token() == first
+ assert mock_session.get_credentials.call_count == 1 + MAX_ATTEMPTS
+ assert [call.delay for call in scheduler.pending] == [MAX_DELAY]
+
+
+def test_get_token_does_not_restart_a_refresh_during_the_backoff(clock, scheduler, sleep):
+ session = fake_session()
+ manager = _manager(session)
+ manager.get_token()
+ _failing_then_ok(session, failures=1_000)
+ clock.advance(DEFAULT_REFRESH_AFTER_SECONDS)
+ scheduler.fire_pending()
+ attempts = session.get_credentials.call_count
+
+ clock.advance(MAX_DELAY - 1)
+ manager.get_token()
+ assert session.get_credentials.call_count == attempts
+ assert [call.delay for call in scheduler.pending] == [MAX_DELAY]
+
+ clock.advance(1)
+ manager.get_token() # backoff over and timer not fired: treated as missed
+ assert [call.delay for call in scheduler.pending] == [0]
+
+
+def test_raises_token_refresh_error_once_the_cached_token_expired(
+ clock, scheduler, sleep
+):
+ session = fake_session()
+ manager = _manager(session)
+ manager.get_token()
+ _failing_then_ok(session, failures=1_000)
+ clock.advance(DEFAULT_REFRESH_AFTER_SECONDS)
+ scheduler.fire_pending() # background cycle fails; token still valid
+
+ clock.advance(EFFECTIVE_LIFETIME)
+ with pytest.raises(TokenRefreshError) as excinfo:
+ manager.get_token()
+
+ assert "before the previous token expired" in str(excinfo.value)
+ assert isinstance(excinfo.value.__cause__, RuntimeError)
+ assert "transient failure" not in str(excinfo.value)
+
+
+def test_fallback_token_is_rechecked_after_the_lock_is_released(
+ clock, scheduler, sleep, mock_session, monkeypatch
+):
+ # The cycle decides "serve the still-valid token" under the lock, then logs with the
+ # lock released. If the token expires in that gap, a waiter that joined because its
+ # token had expired must not receive it.
+ manager = _manager(mock_session)
+ manager.get_token()
+ _failing_then_ok(mock_session, failures=1_000)
+ clock.advance(EFFECTIVE_LIFETIME - 1) # 1s of validity left when the cycle ends
+ monkeypatch.setattr(
+ token_manager._logger, "warning", lambda *a, **k: clock.advance(1)
+ )
+
+ # refresh_token() runs the cycle in the foreground. Without the re-check the
+ # cycle would hand back the now-expired token and refresh_token() would complain
+ # about a missing replacement; with it, the expiry itself is reported.
+ with pytest.raises(TokenRefreshError, match="before the previous token expired"):
+ manager.refresh_token()
+
+
+def test_expired_token_with_a_configuration_error_is_a_refresh_error(
+ clock, scheduler, sleep, mock_session
+):
+ manager = _manager(mock_session)
+ manager.get_token()
+ mock_session.get_credentials.return_value = None
+ clock.advance(EFFECTIVE_LIFETIME)
+
+ with pytest.raises(TokenRefreshError) as excinfo:
+ manager.get_token()
+
+ assert isinstance(excinfo.value.__cause__, ConfigurationError)
+
+
+# --- refresh_token -----------------------------------------------------------------
+
+
+def test_refresh_token_signs_a_replacement_immediately(clock, scheduler):
+ session = fake_session()
+ manager = _manager(session)
+ first = manager.get_token()
+ session.get_credentials.return_value.get_frozen_credentials.return_value = (
+ ROTATED_CREDENTIALS
+ )
+
+ replacement = manager.refresh_token()
+
+ assert replacement != first
+ assert manager.get_token() == replacement
+ assert session.get_credentials.call_count == 2
+ # The replacement gets a fresh refresh timer.
+ assert [call.delay for call in scheduler.pending] == [DEFAULT_REFRESH_AFTER_SECONDS]
+
+
+def test_refresh_token_with_no_cached_token_signs_the_first_one(clock, mock_session):
+ manager = _manager(mock_session)
+
+ token = manager.refresh_token()
+
+ assert manager.get_token() == token
+ assert mock_session.get_credentials.call_count == 1
+
+
+def test_refresh_token_raises_when_no_replacement_could_be_signed(clock, sleep):
+ session = fake_session()
+ manager = _manager(session)
+ first = manager.get_token()
+ _failing_then_ok(session, failures=1_000)
+
+ with pytest.raises(TokenRefreshError, match="replacement"):
+ manager.refresh_token()
+ # The still-valid token keeps being served to ordinary callers.
+ assert manager.get_token() == first
+
+
+def test_refresh_token_after_close_raises(clock, mock_session):
+ manager = _manager(mock_session)
+ manager.close()
+
+ with pytest.raises(TokenRefreshError):
+ manager.refresh_token()
+
+
+# --- on_token_changed --------------------------------------------------------------
+
+
+def test_timer_firing_during_a_slow_callback_still_reschedules(clock, scheduler):
+ # The callback runs after the signing flight is closed, so a timer that fires while
+ # the callback is still running starts its own cycle instead of joining the old
+ # one, and a replacement refresh is always scheduled.
+ session = fake_session()
+ manager = _manager(session, refresh_after=0.01)
+ calls = []
+
+ def slow_callback(token):
+ calls.append(token)
+ if len(calls) == 1:
+ clock.advance(0.01)
+ scheduler.fire_pending() # the timer fires while we are "still in" the callback
+
+ manager._on_token_changed = slow_callback
+ manager.get_token()
+
+ assert session.get_credentials.call_count == 2 # first token + the timer's refresh
+ assert len(calls) == 2
+ assert [call.delay for call in scheduler.pending] == [0.01] # replacement scheduled
+
+
+def test_on_token_changed_receives_each_installed_token(clock, scheduler):
+ session = fake_session()
+ seen = []
+ manager = _manager(session, on_token_changed=seen.append)
+
+ first = manager.get_token()
+ assert seen == [first]
+
+ session.get_credentials.return_value.get_frozen_credentials.return_value = (
+ ROTATED_CREDENTIALS
+ )
+ clock.advance(DEFAULT_REFRESH_AFTER_SECONDS)
+ scheduler.fire_pending()
+
+ assert seen == [first, manager.get_token()]
+ assert seen[0] != seen[1]
+
+
+def test_on_token_changed_failures_are_ignored(clock, mock_session):
+ def explode(_token):
+ raise RuntimeError("consumer bug")
+
+ manager = _manager(mock_session, on_token_changed=explode)
+
+ token = manager.get_token()
+
+ assert manager.get_token() == token
+ assert mock_session.get_credentials.call_count == 1
+
+
+def test_on_token_changed_runs_outside_the_lock(clock, mock_session):
+ # A client may re-authenticate and ask for the credentials again from inside the
+ # callback, and other threads must not be blocked behind it.
+ seen = []
+ manager = _manager(
+ mock_session,
+ on_token_changed=lambda _t: seen.append(
+ (manager.get_credentials(), manager._lock.locked())
+ ),
+ )
+
+ token = _run_bounded(manager.get_token) # a deadlock here must fail, not hang
+
+ assert seen == [((USER, token), False)]
+
+
+def test_on_token_changed_failure_is_logged_without_the_token(
+ clock, mock_session, caplog
+):
+ def explode(_token):
+ raise RuntimeError("consumer bug")
+
+ manager = _manager(mock_session, on_token_changed=explode)
+
+ with caplog.at_level("WARNING", logger="developer_toolkit_elasticache"):
+ token = manager.get_token()
+
+ assert [r.levelname for r in caplog.records] == ["WARNING"]
+ assert "on_token_changed" in caplog.text
+ assert "RuntimeError" in caplog.text
+ # The callback received the token, so its message might contain it: never logged.
+ assert "consumer bug" not in caplog.text
+ assert token not in caplog.text
+
+
+def test_failed_background_refresh_is_logged_without_the_token(
+ clock, scheduler, sleep, caplog
+):
+ session = fake_session()
+ manager = _manager(session)
+ token = manager.get_token()
+ _failing_then_ok(session, failures=1_000)
+ clock.advance(DEFAULT_REFRESH_AFTER_SECONDS)
+
+ with caplog.at_level("WARNING", logger="developer_toolkit_elasticache"):
+ scheduler.fire_pending()
+
+ assert [r.levelname for r in caplog.records] == ["WARNING"]
+ assert "Could not refresh" in caplog.text
+ assert "RuntimeError" in caplog.text
+ assert "retrying in 5s" in caplog.text
+ assert token not in caplog.text
+
+
+def test_on_token_changed_is_not_called_for_a_failed_refresh(clock, scheduler, sleep):
+ session = fake_session()
+ seen = []
+ manager = _manager(session, on_token_changed=seen.append)
+ manager.get_token()
+ _failing_then_ok(session, failures=1_000)
+
+ clock.advance(DEFAULT_REFRESH_AFTER_SECONDS)
+ scheduler.fire_pending()
+
+ assert len(seen) == 1
+
+
+# --- close -------------------------------------------------------------------------
+
+
+def test_close_rejects_later_requests(clock, mock_session):
+ manager = _manager(mock_session)
+ manager.get_token()
+
+ manager.close()
+
+ with pytest.raises(TokenRefreshError):
+ manager.get_token()
+ with pytest.raises(TokenRefreshError):
+ manager.get_credentials()
+
+
+def test_close_is_idempotent(clock, mock_session):
+ manager = _manager(mock_session)
+ manager.get_token()
+
+ manager.close()
+ manager.close()
+
+ with pytest.raises(TokenRefreshError):
+ manager.get_token()
+
+
+def test_context_manager_closes(clock, mock_session):
+ with _manager(mock_session) as manager:
+ manager.get_token()
+
+ with pytest.raises(TokenRefreshError):
+ manager.get_token()
+
+
+def test_close_cancels_the_pending_refresh(clock, scheduler, mock_session):
+ manager = _manager(mock_session)
+ manager.get_token()
+ (pending,) = scheduler.pending
+
+ manager.close()
+
+ assert pending.cancelled
+ pending.callback() # a timer that already fired must be a no-op after close
+ assert mock_session.get_credentials.call_count == 1
+
+
+def test_close_during_a_retry_stops_the_cycle(clock, sleep):
+ session = fake_session()
+ _failing_then_ok(session, failures=1_000)
+ manager = _manager(session)
+ sleep.on_sleep = manager.close
+
+ with pytest.raises(TokenRefreshError) as excinfo:
+ manager.get_token()
+
+ assert "closed" in str(excinfo.value)
+ assert isinstance(excinfo.value.__cause__, RuntimeError)
+ assert session.get_credentials.call_count == 1
+
+
+def test_close_during_signing_discards_the_token(clock):
+ session = fake_session()
+ seen = []
+ manager = _manager(session, on_token_changed=seen.append)
+ credentials = session.get_credentials.return_value
+
+ def close_mid_signing():
+ manager.close()
+ return credentials
+
+ session.get_credentials.side_effect = close_mid_signing
+
+ with pytest.raises(TokenRefreshError):
+ manager.get_token()
+ assert seen == []
+ assert manager._cached is None
+
+
+def test_real_wait_is_interrupted_by_close():
+ stop = threading.Event()
+ waiting = threading.Thread(target=REAL_WAIT, args=(stop, 60), daemon=True)
+ started = time.monotonic()
+ waiting.start()
+ time.sleep(0.1)
+ assert waiting.is_alive() # blocked in the real wait
+
+ stop.set() # what close() does
+
+ waiting.join(timeout=5)
+ assert not waiting.is_alive()
+ assert time.monotonic() - started < 2
+
+
+# --- real scheduler ----------------------------------------------------------------
+
+
+def test_real_timer_refreshes_through_a_slow_callback(monkeypatch):
+ # End to end with the real threading.Timer and wait: a short refresh_after and a
+ # callback slower than it must still produce a steady stream of refreshes.
+ monkeypatch.setattr(token_manager, "_schedule", REAL_SCHEDULE)
+ monkeypatch.setattr(token_manager, "_wait", REAL_WAIT)
+ signed = threading.Semaphore(0)
+
+ def slow_callback(_token):
+ time.sleep(0.05)
+ signed.release()
+
+ with _manager(
+ fake_session(), refresh_after=0.02, on_token_changed=slow_callback
+ ) as m:
+ m.get_token()
+ for _ in range(3):
+ assert signed.acquire(timeout=5), "background refresh did not happen"
diff --git a/scripts/java_dev.py b/scripts/java_dev.py
new file mode 100644
index 0000000..62587d5
--- /dev/null
+++ b/scripts/java_dev.py
@@ -0,0 +1,131 @@
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+# ruff: noqa: INP001, T201, S310, S314, PLR2004 # CLI: prints, parses our own pom, fixed https URLs
+
+import argparse
+import http.client
+import json
+import time
+import urllib.error
+import urllib.request
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+POM = ROOT / "java" / "pom.xml"
+CHANGELOG = ROOT / "java" / "CHANGELOG.md"
+DEPENDENT_POMS = [
+ ROOT / "java" / "examples" / "pom.xml",
+ ROOT / "java" / "integration-tests" / "pom.xml",
+]
+MAVEN_NAMESPACE = {"m": "http://maven.apache.org/POM/4.0.0"}
+TAG_PREFIX = "java-v"
+CENTRAL = "https://repo1.maven.org/maven2"
+# Maven Central usually serves a newly published release within 10-30 minutes.
+SMOKE_ATTEMPTS = 45
+SMOKE_RETRY_SECONDS = 60
+
+
+def fail(message: str) -> None:
+ raise SystemExit(f"::error::{message}")
+
+
+def pom_field(name: str) -> str:
+ value = ET.parse(POM).getroot().findtext(f"m:{name}", namespaces=MAVEN_NAMESPACE)
+ if not value:
+ fail(f"<{name}> is missing from java/pom.xml")
+ return value
+
+
+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 verify(tag: str) -> None:
+ version = pom_field("version")
+ if tag != f"{TAG_PREFIX}{version}":
+ fail(f"Release tag version does not match java/pom.xml version {version}.")
+ if version.endswith("-SNAPSHOT"):
+ fail(f"java/pom.xml version {version} is a SNAPSHOT; set the release version.")
+ if not CHANGELOG.is_file() or f"## [{version}]" not in CHANGELOG.read_text():
+ fail(f"java/CHANGELOG.md has no [{version}] entry.")
+ # The examples and integration tests depend on the published artifact.
+ for pom in DEPENDENT_POMS:
+ depends_on = (
+ ET.parse(pom)
+ .getroot()
+ .findtext(
+ f"m:dependencies/m:dependency[m:artifactId='{pom_field('artifactId')}']/m:version",
+ namespaces=MAVEN_NAMESPACE,
+ )
+ )
+ if depends_on != version:
+ fail(f"{pom.relative_to(ROOT)} depends on {depends_on}, not {version}.")
+ print(f"Publishing version {version}.")
+
+
+def smoke(version: str) -> None:
+ group = pom_field("groupId")
+ artifact = pom_field("artifactId")
+ base = (
+ f"{CENTRAL}/{group.replace('.', '/')}/{artifact}/{version}/{artifact}-{version}"
+ )
+ artifacts = [".pom", ".jar", "-sources.jar", "-javadoc.jar"]
+ files = artifacts + [suffix + ".asc" for suffix in artifacts]
+ for attempt in range(1, SMOKE_ATTEMPTS + 1):
+ missing = [suffix for suffix in files if not exists(base + suffix)]
+ if not missing:
+ print(f"Smoke test passed for {group}:{artifact}:{version}.")
+ return
+ if attempt == SMOKE_ATTEMPTS:
+ fail(f"{group}:{artifact}:{version} is not on Maven Central: {missing}.")
+ print(
+ f"attempt {attempt}/{SMOKE_ATTEMPTS}: not on Maven Central yet "
+ f"({', '.join(missing)}); retrying in {SMOKE_RETRY_SECONDS}s",
+ flush=True,
+ )
+ time.sleep(SMOKE_RETRY_SECONDS)
+
+
+def exists(url: str) -> bool:
+ request = urllib.request.Request(url, method="HEAD")
+ try:
+ with urllib.request.urlopen(request, timeout=30) as response:
+ return response.status == 200
+ except (OSError, http.client.HTTPException):
+ return False
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ commands = parser.add_subparsers(dest="command", required=True)
+ commands.add_parser("list-supported-versions")
+ commands.add_parser("verify").add_argument("tag")
+ commands.add_parser("smoke").add_argument("version")
+ args = parser.parse_args()
+
+ if args.command == "list-supported-versions":
+ list_supported_versions()
+ elif args.command == "verify":
+ verify(args.tag)
+ elif args.command == "smoke":
+ smoke(args.version)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/node_dev.py b/scripts/node_dev.py
new file mode 100644
index 0000000..e4e1a43
--- /dev/null
+++ b/scripts/node_dev.py
@@ -0,0 +1,92 @@
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+# SPDX-License-Identifier: Apache-2.0
+# ruff: noqa: INP001, T201, S603, S607 # a CLI script that prints and runs npm/node
+
+"""Node release smoke test, called by node-release.yml after publishing.
+
+python3 scripts/node_dev.py smoke VERSION
+"""
+
+import argparse
+import json
+import subprocess
+import tempfile
+import time
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+NODE_DIR = ROOT / "node"
+EXPORTS = [
+ "ConfigurationError",
+ "ElastiCacheIAMAuthTokenManager",
+ "ElastiCacheIAMAuthTokenProvider",
+ "InvalidParameterError",
+ "TokenRefreshError",
+ "ToolkitInputError",
+ "generateIamAuthToken",
+]
+# The registry's package listing has lagged 5+ minutes behind a publish.
+INSTALL_ATTEMPTS = 20
+INSTALL_RETRY_SECONDS = 40
+
+
+def fail(message: str) -> None:
+ raise SystemExit(f"::error::{message}")
+
+
+def smoke(version: str) -> None:
+ name = json.loads((NODE_DIR / "package.json").read_text())["name"]
+ spec = f"{name}@{version}"
+ with tempfile.TemporaryDirectory() as project:
+ Path(project, "package.json").write_text('{"private": true, "type": "module"}')
+ for attempt in range(1, INSTALL_ATTEMPTS + 1):
+ # Exact version, fresh cache, --prefer-online: a cached "not found" must
+ # not outlive the registry catching up.
+ with tempfile.TemporaryDirectory() as cache:
+ command = ["npm", "install", spec, "--no-audit", "--no-fund"]
+ command += ["--ignore-scripts", "--prefer-online", "--cache", cache]
+ result = subprocess.run(command, cwd=project, check=False)
+ if result.returncode == 0:
+ break
+ if attempt == INSTALL_ATTEMPTS:
+ fail(f"Could not install {spec} after {INSTALL_ATTEMPTS} attempts.")
+ print(
+ f"install attempt {attempt}/{INSTALL_ATTEMPTS} failed (registry may lag "
+ f"the upload); retrying in {INSTALL_RETRY_SECONDS}s"
+ )
+ time.sleep(INSTALL_RETRY_SECONDS)
+
+ manifest = Path(project, "node_modules", *name.split("/"), "package.json")
+ installed = json.loads(manifest.read_text())["version"]
+ if installed != version:
+ fail(f"Installed {name}@{installed}, expected {version}.")
+ probe = (
+ f"import * as pkg from {json.dumps(name)};"
+ f"const missing = {json.dumps(EXPORTS)}.filter((n) => !(n in pkg));"
+ "if (missing.length) { console.error(missing.join(', ')); process.exit(1); }"
+ )
+ check = subprocess.run(
+ ["node", "--input-type=module", "-e", probe],
+ cwd=project,
+ capture_output=True,
+ text=True,
+ check=False,
+ )
+ if check.returncode != 0:
+ fail(f"Published package is missing exports: {check.stderr.strip()}")
+ print(f"Smoke test passed for {spec}.")
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser()
+ commands = parser.add_subparsers(dest="command", required=True)
+ smoke_parser = commands.add_parser("smoke")
+ smoke_parser.add_argument("version")
+ args = parser.parse_args()
+
+ if args.command == "smoke":
+ smoke(args.version)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/scripts/python_dev.py b/scripts/python_dev.py
index ee6633b..dda2ef5 100755
--- a/scripts/python_dev.py
+++ b/scripts/python_dev.py
@@ -36,6 +36,8 @@
PACKAGE = "developer-toolkit-elasticache"
IMPORT_NAME = "developer_toolkit_elasticache"
DO_NOT_UPLOAD = "Private :: Do Not Upload"
+# Only release versions (X.Y.Z) are published; no prereleases.
+_RELEASE_VERSION = re.compile(r"\d+\.\d+\.\d+")
_COMMANDS = {
@@ -150,6 +152,12 @@ def verify(args: argparse.Namespace) -> int:
f"({pkg_version}). Bump the version and re-tag."
)
return 1
+ if not _RELEASE_VERSION.fullmatch(pkg_version):
+ print(
+ f"::error::Version {pkg_version} is a prerelease. Only release versions "
+ "(X.Y.Z) are published."
+ )
+ return 1
if DO_NOT_UPLOAD in project.get("classifiers", []):
print(
f"::error::The '{DO_NOT_UPLOAD}' classifier is still set. Remove it in the "