Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion node/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,10 @@ All notable changes to the Node.js package are documented here. The format follo
serverless caches and node-based replication groups.
- `ElastiCacheIAMAuthTokenProvider` for generating fresh tokens when clients connect or
reconnect.
- `ToolkitInputError`, `InvalidParameterError`, and `ConfigurationError`.
- `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/`.
109 changes: 101 additions & 8 deletions node/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# 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 and an async token
provider.
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

Expand Down Expand Up @@ -59,6 +59,9 @@ shared config and credentials files, SSO, assume-role, container credentials, an
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
Expand All @@ -84,9 +87,88 @@ const password = await auth.getToken();
```

Pass `username` and `password` to the client connection configuration. Fetch another token
whenever the client creates a new connection. See
[`examples/connect-iovalkey.mjs`](examples/connect-iovalkey.mjs) for an iovalkey
integration.
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

Expand All @@ -98,15 +180,21 @@ node examples/generate-token.mjs
```

The iovalkey example demonstrates integration with a client that does not provide built-in
ElastiCache IAM authentication. It is an optional dependency and is not added to this
package's runtime dependencies. Install it in the application that runs the example, then
run:
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:
Expand All @@ -115,6 +203,11 @@ The public error hierarchy is:
- `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
Expand Down
58 changes: 41 additions & 17 deletions node/examples/connect-iovalkey.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,32 +11,56 @@
* 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. This
* short-lived example disables automatic reconnects because they would reuse
* the initial token. Long-running applications should create a replacement
* client with a fresh token whenever the connection is lost.
* 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()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: since iovalkey only takes a static password, this example still has to disable reconnects, so the manager's main benefit isn't visible. Could we add a second example with node-redis v5, whose credentialsProvider: { type: "async-credentials-provider", credentials: () => auth.getCredentials().then(([username, password]) => ({ username, password })) } is called on every connect and reconnect? Keep this one as the static-password variant.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added

* again, whenever the connection is lost.
*/
import { Valkey } from "iovalkey";
import { ElastiCacheIAMAuthTokenProvider } from "@aws/developer-toolkit-elasticache";
import { ElastiCacheIAMAuthTokenManager } from "@aws/developer-toolkit-elasticache";

const auth = await ElastiCacheIAMAuthTokenProvider.create({
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 = new Valkey({
host: "my-cache-abc123.serverless.use1.cache.amazonaws.com",
port: 6379,
username: auth.userId,
password: await auth.getToken(),
tls: {},
retryStrategy: () => null,
});
async function connect() {
const [username, password] = await auth.getCredentials();
return new Valkey({
host: ENDPOINT,
port: 6379,
username,
password,
tls: {},
retryStrategy: () => null,
});
}

try {
await client.set("hello", "world");
console.log(await client.get("hello"));
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 {
client.disconnect();
auth.close();
}
56 changes: 56 additions & 0 deletions node/examples/connect-node-redis.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
12 changes: 12 additions & 0 deletions node/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,15 @@ export class ConfigurationError extends ToolkitInputError {
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";
}
}
8 changes: 8 additions & 0 deletions node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
export {
ConfigurationError,
InvalidParameterError,
TokenRefreshError,
ToolkitInputError,
} from "./errors.js";
export type {
Expand All @@ -14,3 +15,10 @@ export {
ElastiCacheIAMAuthTokenProvider,
generateIamAuthToken,
} from "./token-generator.js";
export type {
TokenManagerDependencies,
TokenManagerOptions,
TokenManagerScheduler,
TokenManagerTimer,
} from "./token-manager.js";
export { ElastiCacheIAMAuthTokenManager } from "./token-manager.js";
17 changes: 14 additions & 3 deletions node/src/token-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import type {

import { ConfigurationError, InvalidParameterError } from "./errors.js";

const TOKEN_TTL_SECONDS = 900;
/** 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
Expand Down Expand Up @@ -346,11 +347,21 @@ export class ElastiCacheIAMAuthTokenProvider {
dependencies: TokenGeneratorDependencies = {},
): Promise<ElastiCacheIAMAuthTokenProvider> {
const provider = new ElastiCacheIAMAuthTokenProvider(options, dependencies);
const regionResolution = await provider.regionResolution;
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<void> {
const regionResolution = await this.regionResolution;
if ("error" in regionResolution) {
throw regionResolution.error;
}
return provider;
}

get userId(): string {
Expand Down
Loading
Loading