From b79179f96cd0721a8f06fc682c39b192ff5f152a Mon Sep 17 00:00:00 2001 From: Edward Liang <76571219+edlng@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:01:42 -0700 Subject: [PATCH 01/40] feat(node): add node implementation (#9) Signed-off-by: Edward Liang --- .gitattributes | 1 + .github/dependabot.yml | 9 + .github/workflows/node.yml | 78 + .gitignore | 2 +- README.md | 36 +- node/.gitignore | 14 + node/.prettierignore | 4 + node/.prettierrc.json | 6 + node/CHANGELOG.md | 16 + node/LICENSE | 202 +++ node/NOTICE | 2 + node/README.md | 140 ++ node/eslint.config.mjs | 23 + node/examples/connect-iovalkey.mjs | 42 + node/examples/generate-token.mjs | 15 + node/package-lock.json | 1944 +++++++++++++++++++++ node/package.json | 57 + node/scripts/clean.mjs | 8 + node/scripts/list-supported-versions.mjs | 50 + node/src/errors.ts | 33 + node/src/index.ts | 16 + node/src/token-generator.ts | 375 ++++ node/test/list-supported-versions.test.ts | 109 ++ node/test/package.test.ts | 142 ++ node/test/token-generator.test.ts | 595 +++++++ node/tsconfig.json | 19 + node/tsconfig.test.json | 10 + 27 files changed, 3941 insertions(+), 7 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/node.yml create mode 100644 node/.gitignore create mode 100644 node/.prettierignore create mode 100644 node/.prettierrc.json create mode 100644 node/CHANGELOG.md create mode 100644 node/LICENSE create mode 100644 node/NOTICE create mode 100644 node/README.md create mode 100644 node/eslint.config.mjs create mode 100644 node/examples/connect-iovalkey.mjs create mode 100644 node/examples/generate-token.mjs create mode 100644 node/package-lock.json create mode 100644 node/package.json create mode 100644 node/scripts/clean.mjs create mode 100644 node/scripts/list-supported-versions.mjs create mode 100644 node/src/errors.ts create mode 100644 node/src/index.ts create mode 100644 node/src/token-generator.ts create mode 100644 node/test/list-supported-versions.test.ts create mode 100644 node/test/package.test.ts create mode 100644 node/test/token-generator.test.ts create mode 100644 node/tsconfig.json create mode 100644 node/tsconfig.test.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94f480d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c181f1d..863a9e4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -30,3 +30,12 @@ updates: groups: tooling: patterns: ["*"] + + # Node runtime/dev dependencies in node/package.json. + - package-ecosystem: npm + directory: /node + schedule: + interval: weekly + groups: + node: + patterns: ["*"] diff --git a/.github/workflows/node.yml b/.github/workflows/node.yml new file mode 100644 index 0000000..175c123 --- /dev/null +++ b/.github/workflows/node.yml @@ -0,0 +1,78 @@ +name: Node CI + +# Path-filtered so the Node package is validated independently. +on: + push: + branches: + - main + - "release-*" + paths: + - "node/**" + - ".github/workflows/node.yml" + pull_request: + paths: + - "node/**" + - ".github/workflows/node.yml" + +permissions: + contents: read + +jobs: + # Load the test matrix from package.json's supportedNodeVersions. + versions: + name: node versions + runs-on: ubuntu-latest + outputs: + data: ${{ steps.set.outputs.data }} + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Generate supported versions from package.json + id: set + working-directory: node + run: echo "data=$(npm run --silent list-supported-versions)" >> "$GITHUB_OUTPUT" + + test: + name: Node ${{ matrix.node-version }} (${{ matrix.os }}) + needs: versions + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + node-version: ${{ fromJson(needs.versions.outputs.data).all }} + defaults: + run: + working-directory: node + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js ${{ matrix.node-version }} + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: ${{ matrix.node-version }} + cache: npm + cache-dependency-path: node/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Check formatting + run: npm run format:check + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test + + - name: Build + run: npm run build + + - name: Validate package contents + run: npm pack --dry-run diff --git a/.gitignore b/.gitignore index 84a6b4b..106bde7 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,5 @@ *.iml .vscode/ -# Language-specific ignores live in python/.gitignore and java/.gitignore so +# Language-specific ignores live in python/.gitignore and node/.gitignore so # each package stays self-contained. diff --git a/README.md b/README.md index 143d99c..0ef57bf 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,24 @@ # Developer Toolkit for ElastiCache -This repository contains tools to work with [Amazon ElastiCache](https://aws.amazon.com/elasticache/). +This repository contains libraries and tools for working with +[Amazon ElastiCache](https://aws.amazon.com/elasticache/). -The Python library offers [IAM authentication](https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/auth-iam.html) -for ElastiCache: generates a signed token that ElastiCache requires for connection -with an IAM-enabled user, so applications can authenticate to a Valkey or -Redis OSS cache with AWS credentials. +The Python and Node.js libraries support +[IAM authentication](https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/auth-iam.html) +for ElastiCache. They generate the signed token required to connect with an +IAM-enabled user, allowing applications to authenticate to a Valkey or Redis OSS +cache with AWS credentials. ## Packages | Language | Package | Documentation | |---|---|---| | Python | [`developer-toolkit-elasticache`](https://pypi.org/project/developer-toolkit-elasticache/) on PyPI | [python/README.md](python/README.md) | +| Node.js | `@aws/developer-toolkit-elasticache` on npm | [node/README.md](node/README.md) | +## Quick start -## Quick start (Python) +### Python ```bash python3 -m pip install developer-toolkit-elasticache @@ -34,6 +38,26 @@ Use the returned token as the password when connecting to the cache. See the [Python README](python/README.md) for requirements, the credential chain, the Python command-line tool, and client integration examples. +### Node.js + +```bash +npm install @aws/developer-toolkit-elasticache +``` + +```typescript +import { generateIamAuthToken } from "@aws/developer-toolkit-elasticache"; + +const token = await generateIamAuthToken({ + serverlessCacheName: "my-cache", // or replicationGroupId: "my-group" + userId: "iam-user", + region: "us-east-1", +}); +``` + +Use the returned token as the password when connecting to the cache. See the +[Node.js README](node/README.md) for requirements, the credential and region +chains, the async token provider, and client integration examples. + ## Getting help Please use these community resources for getting help. We use the GitHub issues for diff --git a/node/.gitignore b/node/.gitignore new file mode 100644 index 0000000..4abc033 --- /dev/null +++ b/node/.gitignore @@ -0,0 +1,14 @@ +# Build artifacts +dist/ +dist-test/ +node_modules/ + +# npm +*.tgz +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..b50d05b --- /dev/null +++ b/node/CHANGELOG.md @@ -0,0 +1,16 @@ +# 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/). + +## [Unreleased] + +### 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. +- `ToolkitInputError`, `InvalidParameterError`, and `ConfigurationError`. +- Runnable token-generation and iovalkey integration examples under `examples/`. 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..ad60e40 --- /dev/null +++ b/node/README.md @@ -0,0 +1,140 @@ +# 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. + +## 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. + +### 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. See +[`examples/connect-iovalkey.mjs`](examples/connect-iovalkey.mjs) for an iovalkey +integration. + +### 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. 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: + +```bash +npm install iovalkey +node examples/connect-iovalkey.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. + +## 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..d68d540 --- /dev/null +++ b/node/examples/connect-iovalkey.mjs @@ -0,0 +1,42 @@ +// 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. 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. + */ +import { Valkey } from "iovalkey"; +import { ElastiCacheIAMAuthTokenProvider } from "@aws/developer-toolkit-elasticache"; + +const auth = await ElastiCacheIAMAuthTokenProvider.create({ + serverlessCacheName: "my-cache", + userId: "my-iam-user", + region: "us-east-1", +}); + +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, +}); + +try { + await client.set("hello", "world"); + console.log(await client.get("hello")); +} finally { + client.disconnect(); +} 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..252d1a4 --- /dev/null +++ b/node/package-lock.json @@ -0,0 +1,1944 @@ +{ + "name": "@aws/developer-toolkit-elasticache", + "version": "0.1.0-dev.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@aws/developer-toolkit-elasticache", + "version": "0.1.0-dev.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..0fa62ba --- /dev/null +++ b/node/package.json @@ -0,0 +1,57 @@ +{ + "name": "@aws/developer-toolkit-elasticache", + "version": "0.1.0-dev.0", + "description": "Developer toolkit for Amazon ElastiCache", + "type": "module", + "private": true, + "license": "Apache-2.0", + "engines": { + "node": ">=22" + }, + "supportedNodeVersions": [ + "22.x", + "24.x" + ], + "files": [ + "dist", + "CHANGELOG.md", + "LICENSE", + "NOTICE", + "README.md" + ], + "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", + "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/src/errors.ts b/node/src/errors.ts new file mode 100644 index 0000000..dbbb6b6 --- /dev/null +++ b/node/src/errors.ts @@ -0,0 +1,33 @@ +// 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"; + } +} diff --git a/node/src/index.ts b/node/src/index.ts new file mode 100644 index 0000000..e3d7192 --- /dev/null +++ b/node/src/index.ts @@ -0,0 +1,16 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +export { + ConfigurationError, + InvalidParameterError, + ToolkitInputError, +} from "./errors.js"; +export type { + TokenGeneratorDependencies, + TokenGeneratorOptions, +} from "./token-generator.js"; +export { + ElastiCacheIAMAuthTokenProvider, + generateIamAuthToken, +} from "./token-generator.js"; diff --git a/node/src/token-generator.ts b/node/src/token-generator.ts new file mode 100644 index 0000000..b3d6599 --- /dev/null +++ b/node/src/token-generator.ts @@ -0,0 +1,375 @@ +// 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"; + +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); + const regionResolution = await provider.regionResolution; + if ("error" in regionResolution) { + throw regionResolution.error; + } + return provider; + } + + 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/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..0bf233e --- /dev/null +++ b/node/test/package.test.ts @@ -0,0 +1,142 @@ +// 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, ElastiCacheIAMAuthTokenProvider, InvalidParameterError, ToolkitInputError, generateIamAuthToken } from "@aws/developer-toolkit-elasticache";', + 'assert.equal(typeof generateIamAuthToken, "function");', + 'assert.equal(typeof ElastiCacheIAMAuthTokenProvider, "function");', + "assert.equal(ConfigurationError.prototype instanceof ToolkitInputError, true);", + "assert.equal(ConfigurationError.prototype instanceof InvalidParameterError, false);", + ].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.ToolkitInputError, "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, + ); +}); 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/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"] +} From 22bb7ed40fd619ca3a65ba4521f9920c7d3feccf Mon Sep 17 00:00:00 2001 From: "Alexandre [C] Luc Rehnby-Martin" Date: Wed, 2 Sep 2026 08:47:49 -0700 Subject: [PATCH 02/40] Add Java ElastiCache IAM authentication library --- .github/dependabot.yml | 9 + .github/workflows/java.yml | 47 +++ README.md | 20 +- java/.gitignore | 4 + java/LICENSE | 202 +++++++++++++ java/NOTICE | 2 + java/README.md | 74 +++++ java/pom.xml | 180 +++++++++++ .../auth/ConfigurationException.java | 31 ++ .../auth/ElastiCacheIamAuthTokenProvider.java | 285 ++++++++++++++++++ .../auth/InvalidParameterException.java | 21 ++ .../auth/ToolkitInputException.java | 31 ++ .../amazon/elasticache/auth/package-info.java | 9 + .../ElastiCacheIamAuthTokenProviderTest.java | 247 +++++++++++++++ 14 files changed, 1160 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/java.yml create mode 100644 java/.gitignore create mode 100644 java/LICENSE create mode 100644 java/NOTICE create mode 100644 java/README.md create mode 100644 java/pom.xml create mode 100644 java/src/main/java/software/amazon/elasticache/auth/ConfigurationException.java create mode 100644 java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java create mode 100644 java/src/main/java/software/amazon/elasticache/auth/InvalidParameterException.java create mode 100644 java/src/main/java/software/amazon/elasticache/auth/ToolkitInputException.java create mode 100644 java/src/main/java/software/amazon/elasticache/auth/package-info.java create mode 100644 java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 863a9e4..c921e09 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -39,3 +39,12 @@ updates: groups: node: patterns: ["*"] + + # Java runtime and test dependencies in java/pom.xml. + - package-ecosystem: maven + directory: /java + schedule: + interval: weekly + groups: + java: + patterns: ["*"] diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml new file mode 100644 index 0000000..c44b843 --- /dev/null +++ b/.github/workflows/java.yml @@ -0,0 +1,47 @@ +name: Java CI + +on: + push: + branches: [main] + paths: + - "java/**" + - ".github/workflows/java.yml" + pull_request: + paths: + - "java/**" + - ".github/workflows/java.yml" + +jobs: + test: + name: Java ${{ matrix.java-version }} on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest] + java-version: ["8", "11", "17", "21"] + include: + - os: windows-latest + java-version: "8" + - os: windows-latest + java-version: "21" + - os: macos-14 + java-version: "8" + - os: macos-14 + java-version: "21" + + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Java ${{ matrix.java-version }} + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + with: + distribution: corretto + java-version: ${{ matrix.java-version }} + cache: maven + cache-dependency-path: java/pom.xml + + - name: Build and test + working-directory: java + run: mvn --batch-mode --no-transfer-progress verify diff --git a/README.md b/README.md index 0ef57bf..466ec61 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ This repository contains libraries and tools for working with [Amazon ElastiCache](https://aws.amazon.com/elasticache/). -The Python and Node.js libraries support +The Python, Java, and Node.js libraries offer [IAM authentication](https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/auth-iam.html) for ElastiCache. They generate the signed token required to connect with an IAM-enabled user, allowing applications to authenticate to a Valkey or Redis OSS @@ -14,6 +14,7 @@ cache with AWS credentials. | Language | Package | Documentation | |---|---|---| | Python | [`developer-toolkit-elasticache`](https://pypi.org/project/developer-toolkit-elasticache/) on PyPI | [python/README.md](python/README.md) | +| Java | `software.amazon.elasticache:developer-toolkit-elasticache` | [java/README.md](java/README.md) | | Node.js | `@aws/developer-toolkit-elasticache` on npm | [node/README.md](node/README.md) | ## Quick start @@ -38,6 +39,22 @@ Use the returned token as the password when connecting to the cache. See the [Python README](python/README.md) for requirements, the credential chain, the Python command-line tool, and client integration examples. +### Java + +```java +ElastiCacheIamAuthTokenProvider auth = ElastiCacheIamAuthTokenProvider.builder() + .serverlessCacheName("my-cache") + .userId("iam-user") + .region(Region.US_EAST_1) + .build(); + +String username = auth.getUserId(); +String password = auth.getToken(); +``` + +See the [Java README](java/README.md) for Maven setup, region and credential +resolution, and a standalone TLS connection example. + ### Node.js ```bash @@ -66,7 +83,6 @@ tracking bugs and feature requests. - Ask a question or [open a discussion](https://github.com/aws/developer-toolkit-elasticache/discussions). - If you think you may have found a bug, please [open an issue](https://github.com/aws/developer-toolkit-elasticache/issues/new). - ## Contributing Contributions are welcome. Open an issue to discuss a change before sending a pull diff --git a/java/.gitignore b/java/.gitignore new file mode 100644 index 0000000..18d2ca6 --- /dev/null +++ b/java/.gitignore @@ -0,0 +1,4 @@ +target/ +.classpath +.project +.settings/ diff --git a/java/LICENSE b/java/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/java/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/java/NOTICE b/java/NOTICE new file mode 100644 index 0000000..e8a6095 --- /dev/null +++ b/java/NOTICE @@ -0,0 +1,2 @@ +Developer Toolkit for Amazon ElastiCache +Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. diff --git a/java/README.md b/java/README.md new file mode 100644 index 0000000..fdb01b7 --- /dev/null +++ b/java/README.md @@ -0,0 +1,74 @@ +# Developer Toolkit for Amazon ElastiCache (Java) + +This Java library generates the IAM authentication token that Amazon ElastiCache +requires as the connection password for an IAM-enabled user. It supports both +serverless caches and node-based replication groups. + +## Requirements + +- Java 8 or later +- AWS credentials available through the standard AWS SDK credential provider chain + +## Build + +```bash +cd java +mvn verify +``` + +To use the current source build from another local Maven project: + +```bash +mvn install +``` + +```xml + + software.amazon.elasticache + developer-toolkit-elasticache + 0.1.0-SNAPSHOT + +``` + +## Generate a token + +```java +import software.amazon.awssdk.regions.Region; +import software.amazon.elasticache.auth.ElastiCacheIamAuthTokenProvider; + +ElastiCacheIamAuthTokenProvider auth = ElastiCacheIamAuthTokenProvider.builder() + .serverlessCacheName("my-cache") // or replicationGroupId("my-group") + .userId("iam-user") + .region(Region.US_EAST_1) + .build(); + +String username = auth.getUserId(); +String password = auth.getToken(); +``` + +Use `username` and `password` when opening the Redis or Valkey connection. Call +`getToken()` again for each reconnect; tokens are valid for 15 minutes and the +provider reads fresh AWS credentials on every call. + +The region is optional. When omitted, the AWS SDK resolves it from `AWS_REGION`, +`AWS_DEFAULT_REGION`, or the selected AWS config profile. + +Credentials use the AWS SDK default provider chain, including environment +variables, shared credentials/config files, container credentials, and EC2 instance +profile credentials. To use a specific provider, pass it with +`credentialsProvider(...)`. + +## Prerequisites + +The target cache must have TLS enabled and an IAM-enabled ElastiCache user. The +calling AWS identity needs `elasticache:Connect` permission for both the cache and +user resources. + +## 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/pom.xml b/java/pom.xml new file mode 100644 index 0000000..a53c6cb --- /dev/null +++ b/java/pom.xml @@ -0,0 +1,180 @@ + + + + 4.0.0 + + software.amazon.elasticache + developer-toolkit-elasticache + 0.1.0-SNAPSHOT + Developer Toolkit for Amazon ElastiCache + Developer toolkit for Amazon ElastiCache + https://github.com/aws/developer-toolkit-elasticache + + + + Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + UTF-8 + 8 + 2.54.10 + 5.13.4 + + + + + + software.amazon.awssdk + bom + ${aws.sdk.version} + pom + import + + + + + + + software.amazon.awssdk + auth + + + software.amazon.awssdk + sdk-core + + + software.amazon.awssdk + http-client-spi + + + software.amazon.awssdk + http-auth-spi + + + software.amazon.awssdk + identity-spi + + + software.amazon.awssdk + http-auth-aws + + + software.amazon.awssdk + regions + + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test + + + + + + + ${project.basedir} + META-INF + + LICENSE + NOTICE + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.1 + + ${maven.compiler.release} + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + false + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + + software.amazon.elasticache.auth + + + + + + org.apache.maven.plugins + maven-source-plugin + 3.3.1 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.11.2 + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.7.0 + + + analyze-dependencies + verify + + analyze-only + + + true + + + + org.junit.jupiter:junit-jupiter-engine + + + + + + + + + diff --git a/java/src/main/java/software/amazon/elasticache/auth/ConfigurationException.java b/java/src/main/java/software/amazon/elasticache/auth/ConfigurationException.java new file mode 100644 index 0000000..84694b5 --- /dev/null +++ b/java/src/main/java/software/amazon/elasticache/auth/ConfigurationException.java @@ -0,0 +1,31 @@ +/* + * 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); + } + + /** + * Creates a configuration exception with its cause. + * + * @param message actionable error message + * @param cause underlying configuration failure + */ + public ConfigurationException(String message, Throwable cause) { + super(message, cause); + } +} 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..84b8578 --- /dev/null +++ b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java @@ -0,0 +1,285 @@ +/* + * 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.Duration; +import java.util.Locale; +import java.util.Objects; +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.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 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, AWS_DEFAULT_REGION, or the region setting in your AWS config profile."; + 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 ElastiCacheIamAuthTokenProvider(Builder builder) { + Target target = resolveTarget(builder.serverlessCacheName, builder.replicationGroupId); + this.cacheName = target.cacheName; + this.serverless = target.serverless; + this.userId = validateAndNormalize("user_id", builder.userId, USER_ID_PATTERN); + this.region = resolveRegion(builder.region, builder.regionProvider); + this.credentialsProvider = builder.credentialsProvider == null + ? DefaultCredentialsProvider.builder().build() + : builder.credentialsProvider; + this.signer = builder.signer == null ? AwsV4HttpSigner.create() : builder.signer; + } + + /** + * 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 (SdkClientException exception) { + throw new ConfigurationException(NO_CREDENTIALS_MESSAGE, exception); + } + if (credentials == null) { + 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) + .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 'serverless_cache_name' and " + + "'replication_group_id': exactly one must be provided."); + } + if (hasServerless) { + return new Target( + validateAndNormalize( + "serverless_cache_name", serverlessCacheName, CACHE_NAME_PATTERN), + true); + } + return new Target( + validateAndNormalize( + "replication_group_id", replicationGroupId, CACHE_NAME_PATTERN), + false); + } + + private static Region resolveRegion(Region explicitRegion, AwsRegionProvider regionProvider) { + if (explicitRegion != null) { + return explicitRegion; + } + AwsRegionProvider provider = regionProvider == null + ? DefaultAwsRegionProviderChain.builder().build() + : regionProvider; + try { + Region resolved = provider.getRegion(); + if (resolved == null || !hasText(resolved.id())) { + throw new ConfigurationException(NO_REGION_MESSAGE); + } + return resolved; + } catch (SdkClientException exception) { + throw new ConfigurationException(NO_REGION_MESSAGE, exception); + } + } + + 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 AwsRegionProvider regionProvider; + private AwsV4HttpSigner signer; + + 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, the standard AWS region provider chain is used. + * + * @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 signer(AwsV4HttpSigner signer) { + this.signer = Objects.requireNonNull(signer, "signer"); + return this; + } + + /** + * Validates the configuration and creates the provider. + * + * @return configured IAM authentication token provider + */ + public ElastiCacheIamAuthTokenProvider build() { + return new ElastiCacheIamAuthTokenProvider(this); + } + } +} diff --git a/java/src/main/java/software/amazon/elasticache/auth/InvalidParameterException.java b/java/src/main/java/software/amazon/elasticache/auth/InvalidParameterException.java new file mode 100644 index 0000000..d7069fd --- /dev/null +++ b/java/src/main/java/software/amazon/elasticache/auth/InvalidParameterException.java @@ -0,0 +1,21 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.elasticache.auth; + +/** + * Indicates an invalid parameter value or parameter combination. + */ +public final class InvalidParameterException extends ToolkitInputException { + private static final long serialVersionUID = 1L; + + /** + * Creates an invalid parameter exception. + * + * @param message actionable error message + */ + public InvalidParameterException(String message) { + super(message); + } +} diff --git a/java/src/main/java/software/amazon/elasticache/auth/ToolkitInputException.java b/java/src/main/java/software/amazon/elasticache/auth/ToolkitInputException.java new file mode 100644 index 0000000..6ef20d8 --- /dev/null +++ b/java/src/main/java/software/amazon/elasticache/auth/ToolkitInputException.java @@ -0,0 +1,31 @@ +/* + * 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); + } + + /** + * Creates a correctable toolkit exception with its cause. + * + * @param message actionable error message + * @param cause underlying failure + */ + public ToolkitInputException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/java/src/main/java/software/amazon/elasticache/auth/package-info.java b/java/src/main/java/software/amazon/elasticache/auth/package-info.java new file mode 100644 index 0000000..25886ab --- /dev/null +++ b/java/src/main/java/software/amazon/elasticache/auth/package-info.java @@ -0,0 +1,9 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * IAM authentication token generation for Amazon ElastiCache. + */ +package software.amazon.elasticache.auth; diff --git a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java new file mode 100644 index 0000000..a6c325b --- /dev/null +++ b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java @@ -0,0 +1,247 @@ +/* + * 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.util.Arrays; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.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"); + + @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) + .regionProvider(() -> Region.EU_WEST_1) + .build(); + + assertTrue(provider.getToken().contains("%2Feu-west-1%2F")); + } + + @Test + void explicitRegionWinsOverRegionProvider() { + ElastiCacheIamAuthTokenProvider provider = providerBuilder() + .serverlessCacheName(CACHE) + .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 missingRegionFailsAtConstruction() { + ConfigurationException exception = assertThrows( + ConfigurationException.class, + () -> providerBuilder() + .serverlessCacheName(CACHE) + .region(null) + .regionProvider(() -> { + throw SdkClientException.create("missing"); + }) + .build()); + + assertTrue(exception.getMessage().contains("AWS_REGION")); + assertTrue(exception.getMessage().contains("AWS_DEFAULT_REGION")); + } + + @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 missingCredentialsHasActionableError() { + ElastiCacheIamAuthTokenProvider provider = providerBuilder() + .serverlessCacheName(CACHE) + .credentialsProvider(() -> { + throw SdkClientException.create("missing"); + }) + .build(); + + ConfigurationException exception = + assertThrows(ConfigurationException.class, provider::getToken); + assertTrue(exception.getMessage().contains("credentials")); + } + + @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("serverless_cache_name")); + } + } + + @Test + void parameterErrorsDoNotEchoHostileValues() { + String hostile = "evil.com/inject\nlog-entry"; + InvalidParameterException exception = assertThrows( + InvalidParameterException.class, + () -> providerBuilder().serverlessCacheName(hostile).build()); + + assertTrue(exception.getMessage().contains("serverless_cache_name")); + 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("user_id")); + } + + 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 configurationExceptionIsNotAParameterException() { + assertFalse(InvalidParameterException.class.isAssignableFrom(ConfigurationException.class)); + } + + private static ElastiCacheIamAuthTokenProvider.Builder providerBuilder() { + return ElastiCacheIamAuthTokenProvider.builder() + .userId(USER) + .region(REGION) + .credentialsProvider(StaticCredentialsProvider.create(CREDENTIALS)); + } + + 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-Signature")); + assertTrue(token.contains("X-Amz-Expires=900")); + assertFalse(token.startsWith("https://")); + } +} From f7869e681b50de5a2db0f60fffd567672f85d96d Mon Sep 17 00:00:00 2001 From: "Alexandre [C] Luc Rehnby-Martin" Date: Tue, 8 Sep 2026 09:25:41 -0700 Subject: [PATCH 03/40] Support AWS_DEFAULT_REGION in Java auth provider --- java/README.md | 6 ++- .../auth/ElastiCacheIamAuthTokenProvider.java | 44 +++++++++++++++---- .../ElastiCacheIamAuthTokenProviderTest.java | 30 +++++++++++++ 3 files changed, 70 insertions(+), 10 deletions(-) diff --git a/java/README.md b/java/README.md index fdb01b7..c64581f 100644 --- a/java/README.md +++ b/java/README.md @@ -50,8 +50,10 @@ Use `username` and `password` when opening the Redis or Valkey connection. Call `getToken()` again for each reconnect; tokens are valid for 15 minutes and the provider reads fresh AWS credentials on every call. -The region is optional. When omitted, the AWS SDK resolves it from `AWS_REGION`, -`AWS_DEFAULT_REGION`, or the selected AWS config profile. +The region is optional. When omitted, the standard AWS SDK region provider chain +checks the `aws.region` system property, `AWS_REGION`, the selected AWS config +profile, and instance metadata. If that chain does not resolve a region, the +toolkit also checks `AWS_DEFAULT_REGION` for parity with the Python library. Credentials use the AWS SDK default provider chain, including environment variables, shared credentials/config files, container credentials, and EC2 instance diff --git a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java index 84b8578..9d3acb3 100644 --- a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java +++ b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java @@ -8,6 +8,7 @@ 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; @@ -35,6 +36,7 @@ public final class ElastiCacheIamAuthTokenProvider { private static final String URL_SCHEME_PREFIX = "https://"; private static final String SERVICE_NAME = "elasticache"; + private static final String AWS_DEFAULT_REGION_ENV_VAR = "AWS_DEFAULT_REGION"; private static final Duration TOKEN_TTL = Duration.ofSeconds(900); private static final Pattern CACHE_NAME_PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9]*(-[a-zA-Z0-9]+)*$"); @@ -42,7 +44,8 @@ public final class ElastiCacheIamAuthTokenProvider { 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, AWS_DEFAULT_REGION, or the region setting in your AWS config profile."; + + "the aws.region system property, AWS_REGION, AWS_DEFAULT_REGION, " + + "the region setting in your AWS config profile, or instance metadata."; 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."; @@ -59,7 +62,8 @@ private ElastiCacheIamAuthTokenProvider(Builder builder) { this.cacheName = target.cacheName; this.serverless = target.serverless; this.userId = validateAndNormalize("user_id", builder.userId, USER_ID_PATTERN); - this.region = resolveRegion(builder.region, builder.regionProvider); + this.region = resolveRegion( + builder.region, builder.regionProvider, builder.awsDefaultRegionProvider); this.credentialsProvider = builder.credentialsProvider == null ? DefaultCredentialsProvider.builder().build() : builder.credentialsProvider; @@ -150,22 +154,38 @@ private static Target resolveTarget(String serverlessCacheName, String replicati false); } - private static Region resolveRegion(Region explicitRegion, AwsRegionProvider regionProvider) { + private static Region resolveRegion( + Region explicitRegion, + AwsRegionProvider regionProvider, + Supplier awsDefaultRegionProvider) { if (explicitRegion != null) { return explicitRegion; } AwsRegionProvider provider = regionProvider == null ? DefaultAwsRegionProviderChain.builder().build() : regionProvider; + SdkClientException providerFailure = null; try { Region resolved = provider.getRegion(); - if (resolved == null || !hasText(resolved.id())) { - throw new ConfigurationException(NO_REGION_MESSAGE); + if (resolved != null && hasText(resolved.id())) { + return resolved; } - return resolved; } catch (SdkClientException exception) { - throw new ConfigurationException(NO_REGION_MESSAGE, exception); + providerFailure = exception; } + + Supplier fallbackProvider = awsDefaultRegionProvider == null + ? () -> System.getenv(AWS_DEFAULT_REGION_ENV_VAR) + : awsDefaultRegionProvider; + String fallbackRegion = fallbackProvider.get(); + if (hasText(fallbackRegion)) { + return Region.of(fallbackRegion); + } + + if (providerFailure != null) { + throw new ConfigurationException(NO_REGION_MESSAGE, providerFailure); + } + throw new ConfigurationException(NO_REGION_MESSAGE); } private static String validateAndNormalize( @@ -203,6 +223,7 @@ public static final class Builder { private String replicationGroupId; private AwsCredentialsProvider credentialsProvider; private AwsRegionProvider regionProvider; + private Supplier awsDefaultRegionProvider; private AwsV4HttpSigner signer; private Builder() {} @@ -219,7 +240,8 @@ public Builder userId(String userId) { } /** - * Sets the signing region. When omitted, the standard AWS region provider chain is used. + * Sets the signing region. When omitted, the standard AWS region provider chain is used, + * followed by an {@code AWS_DEFAULT_REGION} compatibility fallback. * * @param region AWS region * @return this builder @@ -268,6 +290,12 @@ Builder regionProvider(AwsRegionProvider regionProvider) { return this; } + Builder awsDefaultRegionProvider(Supplier awsDefaultRegionProvider) { + this.awsDefaultRegionProvider = Objects.requireNonNull( + awsDefaultRegionProvider, "awsDefaultRegionProvider"); + return this; + } + Builder signer(AwsV4HttpSigner signer) { this.signer = Objects.requireNonNull(signer, "signer"); return this; diff --git a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java index a6c325b..ac20f35 100644 --- a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java +++ b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java @@ -83,6 +83,34 @@ void explicitRegionWinsOverRegionProvider() { assertFalse(token.contains("eu-west-1")); } + @Test + void awsDefaultRegionIsUsedWhenTheSdkRegionChainFails() { + ElastiCacheIamAuthTokenProvider provider = providerBuilder() + .serverlessCacheName(CACHE) + .region(null) + .regionProvider(() -> { + throw SdkClientException.create("missing"); + }) + .awsDefaultRegionProvider(() -> "ap-southeast-2") + .build(); + + assertTrue(provider.getToken().contains("%2Fap-southeast-2%2F")); + } + + @Test + void sdkRegionChainWinsOverAwsDefaultRegion() { + ElastiCacheIamAuthTokenProvider provider = providerBuilder() + .serverlessCacheName(CACHE) + .region(null) + .regionProvider(() -> Region.EU_WEST_1) + .awsDefaultRegionProvider(() -> "ap-southeast-2") + .build(); + + String token = provider.getToken(); + assertTrue(token.contains("%2Feu-west-1%2F")); + assertFalse(token.contains("ap-southeast-2")); + } + @Test void missingRegionFailsAtConstruction() { ConfigurationException exception = assertThrows( @@ -93,8 +121,10 @@ void missingRegionFailsAtConstruction() { .regionProvider(() -> { throw SdkClientException.create("missing"); }) + .awsDefaultRegionProvider(() -> null) .build()); + assertTrue(exception.getMessage().contains("aws.region")); assertTrue(exception.getMessage().contains("AWS_REGION")); assertTrue(exception.getMessage().contains("AWS_DEFAULT_REGION")); } From f3e4addb13c530fb9b2eb502a7e5639b4eeadcf1 Mon Sep 17 00:00:00 2001 From: "Alexandre [C] Luc Rehnby-Martin" Date: Wed, 9 Sep 2026 08:46:04 -0700 Subject: [PATCH 04/40] Align Java generator with parity contract --- java/CHANGELOG.md | 16 +++ java/README.md | 10 +- .../auth/ElastiCacheIamAuthTokenProvider.java | 60 +++++------ .../ElastiCacheIamAuthTokenProviderTest.java | 101 +++++++++++++++--- 4 files changed, 141 insertions(+), 46 deletions(-) create mode 100644 java/CHANGELOG.md diff --git a/java/CHANGELOG.md b/java/CHANGELOG.md new file mode 100644 index 0000000..30e0fee --- /dev/null +++ b/java/CHANGELOG.md @@ -0,0 +1,16 @@ +# Changelog + +All notable changes to the Java package will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial Java IAM authentication token generator for serverless caches and + node-based replication groups. +- Input validation, lowercase normalization, AWS region and credential resolution, + typed user-correctable errors, and deterministic SigV4 signing tests. +- Maven build, Java version CI matrix, documentation, and dependency updates. diff --git a/java/README.md b/java/README.md index c64581f..1dabe08 100644 --- a/java/README.md +++ b/java/README.md @@ -50,16 +50,18 @@ Use `username` and `password` when opening the Redis or Valkey connection. Call `getToken()` again for each reconnect; tokens are valid for 15 minutes and the provider reads fresh AWS credentials on every call. -The region is optional. When omitted, the standard AWS SDK region provider chain -checks the `aws.region` system property, `AWS_REGION`, the selected AWS config -profile, and instance metadata. If that chain does not resolve a region, the -toolkit also checks `AWS_DEFAULT_REGION` for parity with the Python library. +The region is optional. When omitted, the toolkit checks `AWS_REGION` first, +then uses the standard AWS SDK region provider chain. Credentials use the AWS SDK default provider chain, including environment variables, shared credentials/config files, container credentials, and EC2 instance profile credentials. To use a specific provider, pass it with `credentialsProvider(...)`. +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. + ## Prerequisites The target cache must have TLS enabled and an IAM-enabled ElastiCache user. The diff --git a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java index 9d3acb3..5b9fac4 100644 --- a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java +++ b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java @@ -5,6 +5,7 @@ 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; @@ -18,6 +19,7 @@ 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; @@ -36,7 +38,7 @@ public final class ElastiCacheIamAuthTokenProvider { private static final String URL_SCHEME_PREFIX = "https://"; private static final String SERVICE_NAME = "elasticache"; - private static final String AWS_DEFAULT_REGION_ENV_VAR = "AWS_DEFAULT_REGION"; + private static final String AWS_REGION_ENV_VAR = "AWS_REGION"; private static final Duration TOKEN_TTL = Duration.ofSeconds(900); private static final Pattern CACHE_NAME_PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9]*(-[a-zA-Z0-9]+)*$"); @@ -44,8 +46,7 @@ public final class ElastiCacheIamAuthTokenProvider { 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 " - + "the aws.region system property, AWS_REGION, AWS_DEFAULT_REGION, " - + "the region setting in your AWS config profile, or instance metadata."; + + "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."; @@ -56,6 +57,7 @@ public final class ElastiCacheIamAuthTokenProvider { 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); @@ -63,11 +65,12 @@ private ElastiCacheIamAuthTokenProvider(Builder builder) { this.serverless = target.serverless; this.userId = validateAndNormalize("user_id", builder.userId, USER_ID_PATTERN); this.region = resolveRegion( - builder.region, builder.regionProvider, builder.awsDefaultRegionProvider); + builder.region, builder.awsRegionEnvironmentProvider, builder.regionProvider); this.credentialsProvider = builder.credentialsProvider == null ? DefaultCredentialsProvider.builder().build() : builder.credentialsProvider; - this.signer = builder.signer == null ? AwsV4HttpSigner.create() : builder.signer; + this.signer = AwsV4HttpSigner.create(); + this.signingClock = builder.signingClock == null ? Clock.systemUTC() : builder.signingClock; } /** @@ -123,6 +126,7 @@ public String getToken() { 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(); @@ -156,34 +160,30 @@ private static Target resolveTarget(String serverlessCacheName, String replicati private static Region resolveRegion( Region explicitRegion, - AwsRegionProvider regionProvider, - Supplier awsDefaultRegionProvider) { + 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; - SdkClientException providerFailure = null; try { Region resolved = provider.getRegion(); if (resolved != null && hasText(resolved.id())) { return resolved; } } catch (SdkClientException exception) { - providerFailure = exception; - } - - Supplier fallbackProvider = awsDefaultRegionProvider == null - ? () -> System.getenv(AWS_DEFAULT_REGION_ENV_VAR) - : awsDefaultRegionProvider; - String fallbackRegion = fallbackProvider.get(); - if (hasText(fallbackRegion)) { - return Region.of(fallbackRegion); - } - - if (providerFailure != null) { - throw new ConfigurationException(NO_REGION_MESSAGE, providerFailure); + throw new ConfigurationException(NO_REGION_MESSAGE, exception); } throw new ConfigurationException(NO_REGION_MESSAGE); } @@ -222,9 +222,9 @@ public static final class Builder { private String serverlessCacheName; private String replicationGroupId; private AwsCredentialsProvider credentialsProvider; + private Supplier awsRegionEnvironmentProvider; private AwsRegionProvider regionProvider; - private Supplier awsDefaultRegionProvider; - private AwsV4HttpSigner signer; + private Clock signingClock; private Builder() {} @@ -240,8 +240,8 @@ public Builder userId(String userId) { } /** - * Sets the signing region. When omitted, the standard AWS region provider chain is used, - * followed by an {@code AWS_DEFAULT_REGION} compatibility fallback. + * Sets the signing region. When omitted, {@code AWS_REGION} is checked before the + * standard AWS SDK region provider chain. * * @param region AWS region * @return this builder @@ -290,14 +290,14 @@ Builder regionProvider(AwsRegionProvider regionProvider) { return this; } - Builder awsDefaultRegionProvider(Supplier awsDefaultRegionProvider) { - this.awsDefaultRegionProvider = Objects.requireNonNull( - awsDefaultRegionProvider, "awsDefaultRegionProvider"); + Builder awsRegionEnvironmentProvider(Supplier awsRegionEnvironmentProvider) { + this.awsRegionEnvironmentProvider = Objects.requireNonNull( + awsRegionEnvironmentProvider, "awsRegionEnvironmentProvider"); return this; } - Builder signer(AwsV4HttpSigner signer) { - this.signer = Objects.requireNonNull(signer, "signer"); + Builder signingClock(Clock signingClock) { + this.signingClock = Objects.requireNonNull(signingClock, "signingClock"); return this; } diff --git a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java index ac20f35..9aad09e 100644 --- a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java +++ b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java @@ -10,8 +10,13 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +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 org.junit.jupiter.api.Test; @@ -29,6 +34,10 @@ class ElastiCacheIamAuthTokenProviderTest { 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() { @@ -65,6 +74,7 @@ void resolvesRegionAtConstruction() { ElastiCacheIamAuthTokenProvider provider = providerBuilder() .serverlessCacheName(CACHE) .region(null) + .awsRegionEnvironmentProvider(() -> null) .regionProvider(() -> Region.EU_WEST_1) .build(); @@ -75,6 +85,7 @@ void resolvesRegionAtConstruction() { void explicitRegionWinsOverRegionProvider() { ElastiCacheIamAuthTokenProvider provider = providerBuilder() .serverlessCacheName(CACHE) + .awsRegionEnvironmentProvider(() -> "ap-southeast-2") .regionProvider(() -> Region.EU_WEST_1) .build(); @@ -84,31 +95,30 @@ void explicitRegionWinsOverRegionProvider() { } @Test - void awsDefaultRegionIsUsedWhenTheSdkRegionChainFails() { + void awsRegionEnvironmentWinsOverSdkRegionChain() { ElastiCacheIamAuthTokenProvider provider = providerBuilder() .serverlessCacheName(CACHE) .region(null) - .regionProvider(() -> { - throw SdkClientException.create("missing"); - }) - .awsDefaultRegionProvider(() -> "ap-southeast-2") + .awsRegionEnvironmentProvider(() -> "ap-southeast-2") + .regionProvider(() -> Region.EU_WEST_1) .build(); - assertTrue(provider.getToken().contains("%2Fap-southeast-2%2F")); + String token = provider.getToken(); + assertTrue(token.contains("%2Fap-southeast-2%2F")); + assertFalse(token.contains("eu-west-1")); } @Test - void sdkRegionChainWinsOverAwsDefaultRegion() { + void sdkRegionChainIsUsedWhenAwsRegionIsAbsent() { ElastiCacheIamAuthTokenProvider provider = providerBuilder() .serverlessCacheName(CACHE) .region(null) + .awsRegionEnvironmentProvider(() -> null) .regionProvider(() -> Region.EU_WEST_1) - .awsDefaultRegionProvider(() -> "ap-southeast-2") .build(); String token = provider.getToken(); assertTrue(token.contains("%2Feu-west-1%2F")); - assertFalse(token.contains("ap-southeast-2")); } @Test @@ -118,15 +128,14 @@ void missingRegionFailsAtConstruction() { () -> providerBuilder() .serverlessCacheName(CACHE) .region(null) + .awsRegionEnvironmentProvider(() -> null) .regionProvider(() -> { throw SdkClientException.create("missing"); }) - .awsDefaultRegionProvider(() -> null) .build()); - assertTrue(exception.getMessage().contains("aws.region")); assertTrue(exception.getMessage().contains("AWS_REGION")); - assertTrue(exception.getMessage().contains("AWS_DEFAULT_REGION")); + assertTrue(exception.getMessage().contains("AWS SDK")); } @Test @@ -257,6 +266,32 @@ 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) @@ -264,6 +299,14 @@ private static ElastiCacheIamAuthTokenProvider.Builder providerBuilder() { .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")); @@ -274,4 +317,38 @@ private static void assertValidToken(String token) { assertTrue(token.contains("X-Amz-Expires=900")); assertFalse(token.startsWith("https://")); } + + 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; + } } From e2237d303e83055fa56d46d3226a9da410f0bb4e Mon Sep 17 00:00:00 2001 From: "Alexandre [C] Luc Rehnby-Martin" Date: Wed, 9 Sep 2026 11:57:52 -0700 Subject: [PATCH 05/40] Document Java region environment behavior --- java/CHANGELOG.md | 6 ++++++ java/README.md | 4 ++++ .../elasticache/auth/ElastiCacheIamAuthTokenProvider.java | 3 ++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/java/CHANGELOG.md b/java/CHANGELOG.md index 30e0fee..3fe4826 100644 --- a/java/CHANGELOG.md +++ b/java/CHANGELOG.md @@ -14,3 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Input validation, lowercase normalization, AWS region and credential resolution, typed user-correctable errors, and deterministic SigV4 signing tests. - Maven build, Java version CI matrix, documentation, and dependency updates. + +### Notes + +- Region resolution follows the Java contract: explicit region, then `AWS_REGION`, + then the standard AWS SDK region provider chain. Unlike botocore, the AWS SDK for + Java does not read `AWS_DEFAULT_REGION`. diff --git a/java/README.md b/java/README.md index 1dabe08..b38caa5 100644 --- a/java/README.md +++ b/java/README.md @@ -53,6 +53,10 @@ provider reads fresh AWS credentials on every call. The region is optional. When omitted, the toolkit checks `AWS_REGION` first, then uses the standard AWS SDK region provider chain. +The AWS SDK for Java does not read `AWS_DEFAULT_REGION`. If your environment +uses that variable for Python or AWS CLI tooling, also set `AWS_REGION` or pass +the region explicitly when using this Java package. + Credentials use the AWS SDK default provider chain, including environment variables, shared credentials/config files, container credentials, and EC2 instance profile credentials. To use a specific provider, pass it with diff --git a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java index 5b9fac4..63f628f 100644 --- a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java +++ b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java @@ -241,7 +241,8 @@ public Builder userId(String userId) { /** * Sets the signing region. When omitted, {@code AWS_REGION} is checked before the - * standard AWS SDK region provider chain. + * 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 From a2d014f75b42e18e2c0ba0f0a9dc77cd69ce4309 Mon Sep 17 00:00:00 2001 From: "Alexandre [C] Luc Rehnby-Martin" Date: Wed, 9 Sep 2026 12:14:48 -0700 Subject: [PATCH 06/40] Add Java target validation parity tests --- .../ElastiCacheIamAuthTokenProviderTest.java | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java index 9aad09e..8189110 100644 --- a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java +++ b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java @@ -217,6 +217,22 @@ void rejectsInvalidCacheNamesWithoutEchoingThem() { } } + @Test + void invalidReplicationGroupIdNamesThatParameter() { + InvalidParameterException exception = assertThrows( + InvalidParameterException.class, + () -> providerBuilder().replicationGroupId("bad_name!").build()); + + assertTrue(exception.getMessage().contains("replication_group_id")); + } + + @Test + void emptyCacheNameIsRejectedAsMissingTarget() { + assertThrows( + InvalidParameterException.class, + () -> providerBuilder().serverlessCacheName("").build()); + } + @Test void parameterErrorsDoNotEchoHostileValues() { String hostile = "evil.com/inject\nlog-entry"; From 3d07a2ee99c4fb49ae2fa48092bed9f52fd10a51 Mon Sep 17 00:00:00 2001 From: "Alexandre [C] Luc Rehnby-Martin" Date: Wed, 9 Sep 2026 13:00:12 -0700 Subject: [PATCH 07/40] Add Java IAM connection example --- .github/dependabot.yml | 9 ++ .github/workflows/java.yml | 10 +- java/CHANGELOG.md | 3 + java/README.md | 18 +++ java/examples/pom.xml | 50 ++++++++ .../examples/ConnectServerlessCache.java | 119 ++++++++++++++++++ 6 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 java/examples/pom.xml create mode 100644 java/examples/src/main/java/software/amazon/elasticache/examples/ConnectServerlessCache.java diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c921e09..2db165e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -48,3 +48,12 @@ updates: groups: java: patterns: ["*"] + + # Standalone Java example build plugins. + - package-ecosystem: maven + directory: /java/examples + schedule: + interval: weekly + groups: + java-examples: + patterns: ["*"] diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml index c44b843..047a29b 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/java.yml @@ -40,8 +40,14 @@ jobs: distribution: corretto java-version: ${{ matrix.java-version }} cache: maven - cache-dependency-path: java/pom.xml + cache-dependency-path: | + java/pom.xml + java/examples/pom.xml - name: Build and test working-directory: java - run: mvn --batch-mode --no-transfer-progress verify + run: mvn --batch-mode --no-transfer-progress install + + - name: Compile standalone example + working-directory: java + run: mvn --batch-mode --no-transfer-progress --file examples/pom.xml verify diff --git a/java/CHANGELOG.md b/java/CHANGELOG.md index 3fe4826..ee3c2e4 100644 --- a/java/CHANGELOG.md +++ b/java/CHANGELOG.md @@ -20,3 +20,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Region resolution follows the Java contract: explicit region, then `AWS_REGION`, then the standard AWS SDK region provider chain. Unlike botocore, the AWS SDK for Java does not read `AWS_DEFAULT_REGION`. +- A standalone Java example demonstrates TLS connection, IAM authentication, and + `PING` against an ElastiCache serverless cache without adding a client-library + dependency to the toolkit. diff --git a/java/README.md b/java/README.md index b38caa5..87225dd 100644 --- a/java/README.md +++ b/java/README.md @@ -66,6 +66,24 @@ 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. +## Connect to a serverless cache + +The standalone example in [`examples`](examples) opens a TLS connection, authenticates +with a generated IAM token, and sends `PING`. It uses the Redis serialization protocol +directly so the toolkit does not require a particular Redis or Valkey client. + +Install the toolkit into your local Maven repository, then run the example: + +```bash +mvn install +mvn --file examples/pom.xml compile exec:java \ + -Dexec.args=" " +``` + +Applications should normally use their preferred client library and provide +`auth.getUserId()` and a fresh `auth.getToken()` whenever the client authenticates +or reconnects. + ## Prerequisites The target cache must have TLS enabled and an IAM-enabled ElastiCache user. The diff --git a/java/examples/pom.xml b/java/examples/pom.xml new file mode 100644 index 0000000..40557bd --- /dev/null +++ b/java/examples/pom.xml @@ -0,0 +1,50 @@ + + + + 4.0.0 + + software.amazon.elasticache + developer-toolkit-elasticache-examples + 0.1.0-SNAPSHOT + + + UTF-8 + 8 + + + + + software.amazon.elasticache + developer-toolkit-elasticache + 0.1.0-SNAPSHOT + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.1 + + ${maven.compiler.release} + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + + software.amazon.elasticache.examples.ConnectServerlessCache + + + + + + 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..48ea3fb --- /dev/null +++ b/java/examples/src/main/java/software/amazon/elasticache/examples/ConnectServerlessCache.java @@ -0,0 +1,119 @@ +/* + * 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.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()) { + 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."); + } + + 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)); + } +} From 81398497dc7a94a0e967a1167d8badf191f74eac Mon Sep 17 00:00:00 2001 From: "Alexandre [C] Luc Rehnby-Martin" Date: Wed, 9 Sep 2026 15:27:29 -0700 Subject: [PATCH 08/40] Verify TLS hostnames in Java example --- java/CHANGELOG.md | 3 ++- java/README.md | 7 ++--- java/examples/pom.xml | 13 +++++++++ .../examples/ConnectServerlessCache.java | 8 ++++++ .../examples/ConnectServerlessCacheTest.java | 27 +++++++++++++++++++ 5 files changed, 54 insertions(+), 4 deletions(-) create mode 100644 java/examples/src/test/java/software/amazon/elasticache/examples/ConnectServerlessCacheTest.java diff --git a/java/CHANGELOG.md b/java/CHANGELOG.md index ee3c2e4..94d85dd 100644 --- a/java/CHANGELOG.md +++ b/java/CHANGELOG.md @@ -22,4 +22,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Java does not read `AWS_DEFAULT_REGION`. - A standalone Java example demonstrates TLS connection, IAM authentication, and `PING` against an ElastiCache serverless cache without adding a client-library - dependency to the toolkit. + dependency to the toolkit. The example verifies the endpoint hostname during + the TLS handshake. diff --git a/java/README.md b/java/README.md index 87225dd..e2d8c50 100644 --- a/java/README.md +++ b/java/README.md @@ -68,9 +68,10 @@ succeed, but ElastiCache will reject the token when the client connects. ## Connect to a serverless cache -The standalone example in [`examples`](examples) opens a TLS connection, authenticates -with a generated IAM token, and sends `PING`. It uses the Redis serialization protocol -directly so the toolkit does not require a particular Redis or Valkey client. +The standalone example in [`examples`](examples) opens a TLS connection with certificate +and hostname verification, authenticates with a generated IAM token, and sends `PING`. +It uses the Redis serialization protocol directly so the toolkit does not require a +particular Redis or Valkey client. Install the toolkit into your local Maven repository, then run the example: diff --git a/java/examples/pom.xml b/java/examples/pom.xml index 40557bd..858d2e7 100644 --- a/java/examples/pom.xml +++ b/java/examples/pom.xml @@ -15,6 +15,7 @@ UTF-8 8 + 5.13.4 @@ -23,6 +24,18 @@ developer-toolkit-elasticache 0.1.0-SNAPSHOT + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test + 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 index 48ea3fb..207a528 100644 --- a/java/examples/src/main/java/software/amazon/elasticache/examples/ConnectServerlessCache.java +++ b/java/examples/src/main/java/software/amazon/elasticache/examples/ConnectServerlessCache.java @@ -8,6 +8,7 @@ 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; @@ -57,6 +58,7 @@ public static void main(String[] args) throws IOException { 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()); @@ -69,6 +71,12 @@ public static void main(String[] args) throws IOException { 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) { diff --git a/java/examples/src/test/java/software/amazon/elasticache/examples/ConnectServerlessCacheTest.java b/java/examples/src/test/java/software/amazon/elasticache/examples/ConnectServerlessCacheTest.java new file mode 100644 index 0000000..01ca844 --- /dev/null +++ b/java/examples/src/test/java/software/amazon/elasticache/examples/ConnectServerlessCacheTest.java @@ -0,0 +1,27 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.elasticache.examples; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; + +import org.junit.jupiter.api.Test; + +class ConnectServerlessCacheTest { + @Test + void enablesHostnameVerification() throws IOException { + SSLSocketFactory socketFactory = (SSLSocketFactory) SSLSocketFactory.getDefault(); + try (SSLSocket socket = (SSLSocket) socketFactory.createSocket()) { + ConnectServerlessCache.enableHostnameVerification(socket); + + assertEquals( + "HTTPS", + socket.getSSLParameters().getEndpointIdentificationAlgorithm()); + } + } +} From 2e0ea00333668d63dcee16505e806f5a1be0c9cc Mon Sep 17 00:00:00 2001 From: "Alexandre [C] Luc Rehnby-Martin" Date: Thu, 10 Sep 2026 12:14:37 -0700 Subject: [PATCH 09/40] Expand Java parity coverage --- java/CHANGELOG.md | 2 + .../elasticache/examples/PublicApiTest.java | 26 ++++ .../auth/ElastiCacheIamAuthTokenProvider.java | 6 +- .../ElastiCacheIamAuthTokenProviderTest.java | 113 +++++++++++++++++- 4 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 java/examples/src/test/java/software/amazon/elasticache/examples/PublicApiTest.java diff --git a/java/CHANGELOG.md b/java/CHANGELOG.md index 94d85dd..d2a2731 100644 --- a/java/CHANGELOG.md +++ b/java/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 node-based replication groups. - Input validation, lowercase normalization, AWS region and credential resolution, typed user-correctable errors, and deterministic SigV4 signing tests. +- Credential providers that fail or return unusable credentials are normalized to + an actionable `ConfigurationException`. - Maven build, Java version CI matrix, documentation, and dependency updates. ### Notes diff --git a/java/examples/src/test/java/software/amazon/elasticache/examples/PublicApiTest.java b/java/examples/src/test/java/software/amazon/elasticache/examples/PublicApiTest.java new file mode 100644 index 0000000..4870517 --- /dev/null +++ b/java/examples/src/test/java/software/amazon/elasticache/examples/PublicApiTest.java @@ -0,0 +1,26 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.elasticache.examples; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import software.amazon.elasticache.auth.ConfigurationException; +import software.amazon.elasticache.auth.ElastiCacheIamAuthTokenProvider; +import software.amazon.elasticache.auth.InvalidParameterException; +import software.amazon.elasticache.auth.ToolkitInputException; + +class PublicApiTest { + @Test + void installedArtifactExposesPublicApi() { + assertNotNull(ElastiCacheIamAuthTokenProvider.builder()); + assertTrue(ToolkitInputException.class.isAssignableFrom(ConfigurationException.class)); + assertTrue(ToolkitInputException.class.isAssignableFrom(InvalidParameterException.class)); + assertFalse( + InvalidParameterException.class.isAssignableFrom(ConfigurationException.class)); + } +} diff --git a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java index 63f628f..0078bb2 100644 --- a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java +++ b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java @@ -101,10 +101,12 @@ public String getToken() { AwsCredentials credentials; try { credentials = credentialsProvider.resolveCredentials(); - } catch (SdkClientException exception) { + } catch (RuntimeException exception) { throw new ConfigurationException(NO_CREDENTIALS_MESSAGE, exception); } - if (credentials == null) { + if (credentials == null + || !hasText(credentials.accessKeyId()) + || !hasText(credentials.secretAccessKey())) { throw new ConfigurationException(NO_CREDENTIALS_MESSAGE); } diff --git a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java index 8189110..0e5eb97 100644 --- a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java +++ b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java @@ -18,6 +18,7 @@ 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; @@ -138,6 +139,40 @@ void missingRegionFailsAtConstruction() { assertTrue(exception.getMessage().contains("AWS SDK")); } + @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(); @@ -170,18 +205,81 @@ void rotatedCredentialsChangeTheToken() { 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 SdkClientException.create("missing"); + throw new IllegalStateException(providerDetail); }) .build(); ConfigurationException exception = assertThrows(ConfigurationException.class, provider::getToken); - assertTrue(exception.getMessage().contains("credentials")); + assertEquals( + "No AWS credentials found. Configure credentials via the environment, " + + "shared config/credentials files, or an instance/container role.", + exception.getMessage()); + assertFalse(exception.getMessage().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 @@ -277,6 +375,17 @@ void validatesUserIds() { } } + @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)); From 1d1af48e470011c49dfce4f17fe3cb48b88ecaa4 Mon Sep 17 00:00:00 2001 From: "Alexandre [C] Luc Rehnby-Martin" Date: Thu, 10 Sep 2026 16:48:41 -0700 Subject: [PATCH 10/40] Address Java review feedback --- .github/workflows/java.yml | 2 +- .../elasticache/auth/ConfigurationException.java | 10 ---------- .../auth/ElastiCacheIamAuthTokenProvider.java | 4 ++-- .../elasticache/auth/ToolkitInputException.java | 10 ---------- .../auth/ElastiCacheIamAuthTokenProviderTest.java | 15 +++++++++++++-- 5 files changed, 16 insertions(+), 25 deletions(-) diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml index 047a29b..0b0c8e3 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/java.yml @@ -2,7 +2,7 @@ name: Java CI on: push: - branches: [main] + branches: [main, release-2] paths: - "java/**" - ".github/workflows/java.yml" diff --git a/java/src/main/java/software/amazon/elasticache/auth/ConfigurationException.java b/java/src/main/java/software/amazon/elasticache/auth/ConfigurationException.java index 84694b5..bc46be1 100644 --- a/java/src/main/java/software/amazon/elasticache/auth/ConfigurationException.java +++ b/java/src/main/java/software/amazon/elasticache/auth/ConfigurationException.java @@ -18,14 +18,4 @@ public final class ConfigurationException extends ToolkitInputException { public ConfigurationException(String message) { super(message); } - - /** - * Creates a configuration exception with its cause. - * - * @param message actionable error message - * @param cause underlying configuration failure - */ - public ConfigurationException(String message, Throwable cause) { - super(message, cause); - } } diff --git a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java index 0078bb2..9f5852e 100644 --- a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java +++ b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java @@ -102,7 +102,7 @@ public String getToken() { try { credentials = credentialsProvider.resolveCredentials(); } catch (RuntimeException exception) { - throw new ConfigurationException(NO_CREDENTIALS_MESSAGE, exception); + throw new ConfigurationException(NO_CREDENTIALS_MESSAGE); } if (credentials == null || !hasText(credentials.accessKeyId()) @@ -185,7 +185,7 @@ private static Region resolveRegion( return resolved; } } catch (SdkClientException exception) { - throw new ConfigurationException(NO_REGION_MESSAGE, exception); + throw new ConfigurationException(NO_REGION_MESSAGE); } throw new ConfigurationException(NO_REGION_MESSAGE); } diff --git a/java/src/main/java/software/amazon/elasticache/auth/ToolkitInputException.java b/java/src/main/java/software/amazon/elasticache/auth/ToolkitInputException.java index 6ef20d8..a34d821 100644 --- a/java/src/main/java/software/amazon/elasticache/auth/ToolkitInputException.java +++ b/java/src/main/java/software/amazon/elasticache/auth/ToolkitInputException.java @@ -18,14 +18,4 @@ public class ToolkitInputException extends RuntimeException { public ToolkitInputException(String message) { super(message); } - - /** - * Creates a correctable toolkit exception with its cause. - * - * @param message actionable error message - * @param cause underlying failure - */ - public ToolkitInputException(String message, Throwable cause) { - super(message, cause); - } } diff --git a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java index 0e5eb97..0f1841d 100644 --- a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java +++ b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java @@ -10,6 +10,8 @@ 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; @@ -124,6 +126,7 @@ void sdkRegionChainIsUsedWhenAwsRegionIsAbsent() { @Test void missingRegionFailsAtConstruction() { + String providerDetail = "secret region detail\nmust not escape"; ConfigurationException exception = assertThrows( ConfigurationException.class, () -> providerBuilder() @@ -131,12 +134,13 @@ void missingRegionFailsAtConstruction() { .region(null) .awsRegionEnvironmentProvider(() -> null) .regionProvider(() -> { - throw SdkClientException.create("missing"); + throw SdkClientException.create(providerDetail); }) .build()); assertTrue(exception.getMessage().contains("AWS_REGION")); assertTrue(exception.getMessage().contains("AWS SDK")); + assertFalse(renderStackTrace(exception).contains(providerDetail)); } @Test @@ -258,7 +262,7 @@ void missingCredentialsHasActionableError() { "No AWS credentials found. Configure credentials via the environment, " + "shared config/credentials files, or an instance/container role.", exception.getMessage()); - assertFalse(exception.getMessage().contains(providerDetail)); + assertFalse(renderStackTrace(exception).contains(providerDetail)); } @Test @@ -438,11 +442,18 @@ private static void assertValidToken(String token) { 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 + "/?")); From f8a120bd7043d831d20195a3f8bc881678798047 Mon Sep 17 00:00:00 2001 From: "Alexandre [C] Luc Rehnby-Martin" Date: Fri, 11 Sep 2026 10:46:39 -0700 Subject: [PATCH 11/40] Limit Java workflow token permissions --- .github/workflows/java.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml index 0b0c8e3..cbde212 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/java.yml @@ -11,6 +11,9 @@ on: - "java/**" - ".github/workflows/java.yml" +permissions: + contents: read + jobs: test: name: Java ${{ matrix.java-version }} on ${{ matrix.os }} From 1593a5f42c41c726800be0d93635592883bfc0c8 Mon Sep 17 00:00:00 2001 From: "Alexandre [C] Luc Rehnby-Martin" Date: Fri, 11 Sep 2026 15:23:57 -0700 Subject: [PATCH 12/40] Consolidate project changelog --- CHANGELOG.md | 50 +++++++++++++++++++++++++++++++++++++++++++++ README.md | 2 ++ java/CHANGELOG.md | 28 ------------------------- python/CHANGELOG.md | 24 ---------------------- 4 files changed, 52 insertions(+), 52 deletions(-) create mode 100644 CHANGELOG.md delete mode 100644 java/CHANGELOG.md delete mode 100644 python/CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..361cb64 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,50 @@ +# Changelog + +All notable changes to this project's language packages are documented here. The +format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and each +package uses [Semantic Versioning](https://semver.org/). + +## Java + +### [Unreleased] + +#### Added + +- Initial Java IAM authentication token generator for serverless caches and + node-based replication groups. +- Input validation, lowercase normalization, AWS region and credential resolution, + typed user-correctable errors, and deterministic SigV4 signing tests. +- Credential providers that fail or return unusable credentials are normalized to + an actionable `ConfigurationException`. +- Maven build, Java version CI matrix, documentation, and dependency updates. + +#### Notes + +- Region resolution follows the Java contract: explicit region, then `AWS_REGION`, + then the standard AWS SDK region provider chain. Unlike botocore, the AWS SDK for + Java does not read `AWS_DEFAULT_REGION`. +- A standalone Java example demonstrates TLS connection, IAM authentication, and + `PING` against an ElastiCache serverless cache without adding a client-library + dependency to the toolkit. The example verifies the endpoint hostname during + the TLS handshake. + +## Python + +### [1.0.0] - Unreleased + +Initial release. + +#### Added + +- `generate_iam_auth_token()` — signs an IAM authentication token for an Amazon + ElastiCache serverless cache or node-based replication group using the default AWS + credential chain. +- `ElastiCacheIAMAuthTokenProvider` — signs a fresh token on each `get_token()` call, + for clients that re-authenticate on reconnect. +- `developer-toolkit-elasticache generate_iam_auth_token` command-line interface. +- `InvalidParameterError` and `ConfigurationError` (both `ToolkitInputError`) for + invalid arguments and unresolvable region or credentials. Error messages never echo + caller-provided values. +- Examples for redis-py and valkey-py under `examples/`. + +[1.0.0]: https://github.com/aws/developer-toolkit-elasticache/releases/tag/python-v1.0.0 diff --git a/README.md b/README.md index 466ec61..3156ca0 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,8 @@ cache with AWS credentials. | Java | `software.amazon.elasticache:developer-toolkit-elasticache` | [java/README.md](java/README.md) | | Node.js | `@aws/developer-toolkit-elasticache` on npm | [node/README.md](node/README.md) | +See the project [changelog](CHANGELOG.md) for release notes for all language packages. + ## Quick start ### Python diff --git a/java/CHANGELOG.md b/java/CHANGELOG.md deleted file mode 100644 index d2a2731..0000000 --- a/java/CHANGELOG.md +++ /dev/null @@ -1,28 +0,0 @@ -# Changelog - -All notable changes to the Java package will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -### Added - -- Initial Java IAM authentication token generator for serverless caches and - node-based replication groups. -- Input validation, lowercase normalization, AWS region and credential resolution, - typed user-correctable errors, and deterministic SigV4 signing tests. -- Credential providers that fail or return unusable credentials are normalized to - an actionable `ConfigurationException`. -- Maven build, Java version CI matrix, documentation, and dependency updates. - -### Notes - -- Region resolution follows the Java contract: explicit region, then `AWS_REGION`, - then the standard AWS SDK region provider chain. Unlike botocore, the AWS SDK for - Java does not read `AWS_DEFAULT_REGION`. -- A standalone Java example demonstrates TLS connection, IAM authentication, and - `PING` against an ElastiCache serverless cache without adding a client-library - dependency to the toolkit. The example verifies the endpoint hostname during - the TLS handshake. diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md deleted file mode 100644 index 3b93e89..0000000 --- a/python/CHANGELOG.md +++ /dev/null @@ -1,24 +0,0 @@ -# Changelog - -All notable changes to the Python 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] - Unreleased - -Initial release. - -### Added - -- `generate_iam_auth_token()` — signs an IAM authentication token for an Amazon - ElastiCache serverless cache or node-based replication group using the default AWS - credential chain. -- `ElastiCacheIAMAuthTokenProvider` — signs a fresh token on each `get_token()` call, - for clients that re-authenticate on reconnect. -- `developer-toolkit-elasticache generate_iam_auth_token` command-line interface. -- `InvalidParameterError` and `ConfigurationError` (both `ToolkitInputError`) for - invalid arguments and unresolvable region or credentials. Error messages never echo - caller-provided values. -- Examples for redis-py and valkey-py under `examples/`. - -[1.0.0]: https://github.com/aws/developer-toolkit-elasticache/releases/tag/python-v1.0.0 From c2cb46ab906bcbbdecec592307b89c00832ff6cb Mon Sep 17 00:00:00 2001 From: "Alexandre [C] Luc Rehnby-Martin" Date: Fri, 11 Sep 2026 15:37:17 -0700 Subject: [PATCH 13/40] Address Java integration review feedback --- .github/workflows/java.yml | 35 ++++++-- .gitignore | 7 +- CHANGELOG.md | 7 +- java/README.md | 83 +++++++++++-------- java/examples/pom.xml | 11 +-- .../examples/ConnectWithLettuce.java | 73 ++++++++++++++++ java/integration-tests/pom.xml | 61 ++++++++++++++ .../integration}/PublicApiTest.java | 2 +- java/pom.xml | 1 + .../auth/ElastiCacheIamAuthTokenProvider.java | 10 +-- .../ElastiCacheIamAuthTokenProviderTest.java | 8 +- python/README.md | 4 + scripts/java_dev.py | 45 ++++++++++ 13 files changed, 288 insertions(+), 59 deletions(-) create mode 100644 java/examples/src/main/java/software/amazon/elasticache/examples/ConnectWithLettuce.java create mode 100644 java/integration-tests/pom.xml rename java/{examples/src/test/java/software/amazon/elasticache/examples => integration-tests/src/test/java/software/amazon/elasticache/integration}/PublicApiTest.java (95%) create mode 100644 scripts/java_dev.py diff --git a/.github/workflows/java.yml b/.github/workflows/java.yml index cbde212..06d03b4 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/java.yml @@ -5,33 +5,51 @@ on: branches: [main, release-2] paths: - "java/**" + - "scripts/java_dev.py" - ".github/workflows/java.yml" pull_request: paths: - "java/**" + - "scripts/java_dev.py" - ".github/workflows/java.yml" permissions: contents: read jobs: + versions: + name: Java versions + runs-on: ubuntu-latest + outputs: + data: ${{ steps.set.outputs.data }} + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Load supported versions from pom.xml + id: set + run: echo "data=$(python3 scripts/java_dev.py list-supported-versions)" >> "$GITHUB_OUTPUT" + test: name: Java ${{ matrix.java-version }} on ${{ matrix.os }} + needs: versions runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: + # Every supported version runs on Linux. Other platforms test the + # ends of the range, where runtime differences are most likely. os: [ubuntu-latest] - java-version: ["8", "11", "17", "21"] + java-version: ${{ fromJson(needs.versions.outputs.data).all }} include: - os: windows-latest - java-version: "8" + java-version: "${{ fromJson(needs.versions.outputs.data).min }}" - os: windows-latest - java-version: "21" + java-version: "${{ fromJson(needs.versions.outputs.data).max }}" - os: macos-14 - java-version: "8" + java-version: "${{ fromJson(needs.versions.outputs.data).min }}" - os: macos-14 - java-version: "21" + java-version: "${{ fromJson(needs.versions.outputs.data).max }}" steps: - name: Check out the repository @@ -46,11 +64,16 @@ jobs: cache-dependency-path: | java/pom.xml java/examples/pom.xml + java/integration-tests/pom.xml - name: Build and test working-directory: java run: mvn --batch-mode --no-transfer-progress install - - name: Compile standalone example + - name: Build documentation examples working-directory: java run: mvn --batch-mode --no-transfer-progress --file examples/pom.xml verify + + - name: Run installed-artifact integration tests + working-directory: java + run: mvn --batch-mode --no-transfer-progress --file integration-tests/pom.xml verify diff --git a/.gitignore b/.gitignore index 106bde7..67969fd 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,10 @@ *.iml .vscode/ -# Language-specific ignores live in python/.gitignore and node/.gitignore so +# Python helper scripts at the repository root. +__pycache__/ +*.py[cod] + +# Language-specific ignores live in python/.gitignore, java/.gitignore, and +# node/.gitignore so # each package stays self-contained. diff --git a/CHANGELOG.md b/CHANGELOG.md index 361cb64..8f9e446 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,10 +23,9 @@ package uses [Semantic Versioning](https://semver.org/). - Region resolution follows the Java contract: explicit region, then `AWS_REGION`, then the standard AWS SDK region provider chain. Unlike botocore, the AWS SDK for Java does not read `AWS_DEFAULT_REGION`. -- A standalone Java example demonstrates TLS connection, IAM authentication, and - `PING` against an ElastiCache serverless cache without adding a client-library - dependency to the toolkit. The example verifies the endpoint hostname during - the TLS handshake. +- Java examples demonstrate Lettuce `RedisCredentialsProvider` integration and the + raw TLS/Redis protocol exchange for custom clients and proxies. The wire-level + example verifies the endpoint hostname during the TLS handshake. ## Python diff --git a/java/README.md b/java/README.md index e2d8c50..e2321e4 100644 --- a/java/README.md +++ b/java/README.md @@ -4,24 +4,18 @@ This Java library generates the IAM authentication token that Amazon ElastiCache requires as the connection password for an IAM-enabled user. It supports both serverless caches and node-based replication groups. -## Requirements +## Installation -- Java 8 or later -- AWS credentials available through the standard AWS SDK credential provider chain - -## Build +Requires Java 8 or later and Maven. To build and install the current source into +your local Maven repository: ```bash cd java -mvn verify -``` - -To use the current source build from another local Maven project: - -```bash mvn install ``` +Add the dependency to another Maven project: + ```xml software.amazon.elasticache @@ -30,6 +24,20 @@ mvn install ``` +## What you need + +- An Amazon ElastiCache serverless cache or replication group with: + - Valkey 7.2 or later, or Redis OSS 7.0 or later. + - In-transit encryption (TLS) enabled. + - An IAM-enabled ElastiCache user with access to the cache. +- AWS credentials for an identity with `elasticache:Connect` permission for both + the cache and user resources. + +Credentials use the AWS SDK default provider chain, including environment +variables, shared credentials/config files, container credentials, and EC2 instance +profile credentials. To use a specific provider, pass it with +`credentialsProvider(...)`. + ## Generate a token ```java @@ -46,9 +54,9 @@ String username = auth.getUserId(); String password = auth.getToken(); ``` -Use `username` and `password` when opening the Redis or Valkey connection. Call -`getToken()` again for each reconnect; tokens are valid for 15 minutes and the -provider reads fresh AWS credentials on every call. +Use `username` and `password` when opening the Redis or Valkey connection. Tokens +are valid for 15 minutes, and the provider reads fresh AWS credentials on every +`getToken()` call. The region is optional. When omitted, the toolkit checks `AWS_REGION` first, then uses the standard AWS SDK region provider chain. @@ -57,39 +65,48 @@ The AWS SDK for Java does not read `AWS_DEFAULT_REGION`. If your environment uses that variable for Python or AWS CLI tooling, also set `AWS_REGION` or pass the region explicitly when using this Java package. -Credentials use the AWS SDK default provider chain, including environment -variables, shared credentials/config files, container credentials, and EC2 instance -profile credentials. To use a specific provider, pass it with -`credentialsProvider(...)`. - 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. -## Connect to a serverless cache +## Reconnecting clients -The standalone example in [`examples`](examples) opens a TLS connection with certificate -and hostname verification, authenticates with a generated IAM token, and sends `PING`. -It uses the Redis serialization protocol directly so the toolkit does not require a -particular Redis or Valkey client. +Clients should request fresh credentials whenever they connect or reconnect. The +Lettuce example implements `RedisCredentialsProvider` so each credentials request +calls `getToken()`: -Install the toolkit into your local Maven repository, then run the example: +```java +RedisCredentialsProvider credentialsProvider = new RedisCredentialsProvider() { + @Override + public Mono resolveCredentials() { + return Mono.fromSupplier( + () -> RedisCredentials.just(auth.getUserId(), auth.getToken())); + } +}; +``` + +Run the complete +[`ConnectWithLettuce`](examples/src/main/java/software/amazon/elasticache/examples/ConnectWithLettuce.java) +example after installing the toolkit: ```bash -mvn install mvn --file examples/pom.xml compile exec:java \ + -Dexec.mainClass=software.amazon.elasticache.examples.ConnectWithLettuce \ -Dexec.args=" " ``` -Applications should normally use their preferred client library and provide -`auth.getUserId()` and a fresh `auth.getToken()` whenever the client authenticates -or reconnects. +For custom clients and proxies, +[`ConnectServerlessCache`](examples/src/main/java/software/amazon/elasticache/examples/ConnectServerlessCache.java) +shows the TLS and Redis serialization protocol exchange without a client library: -## Prerequisites +```bash +mvn --file examples/pom.xml compile exec:java \ + -Dexec.mainClass=software.amazon.elasticache.examples.ConnectServerlessCache \ + -Dexec.args=" " +``` -The target cache must have TLS enabled and an IAM-enabled ElastiCache user. The -calling AWS identity needs `elasticache:Connect` permission for both the cache and -user resources. +The wire-level example enables certificate hostname verification before sending +the IAM token. ## Security diff --git a/java/examples/pom.xml b/java/examples/pom.xml index 858d2e7..ba98c0b 100644 --- a/java/examples/pom.xml +++ b/java/examples/pom.xml @@ -15,6 +15,7 @@ UTF-8 8 + 7.7.0.RELEASE 5.13.4 @@ -24,6 +25,11 @@ developer-toolkit-elasticache 0.1.0-SNAPSHOT + + io.lettuce + lettuce-core + ${lettuce.version} + org.junit.jupiter junit-jupiter-api @@ -52,11 +58,6 @@ org.codehaus.mojo exec-maven-plugin 3.5.0 - - - software.amazon.elasticache.examples.ConnectServerlessCache - - diff --git a/java/examples/src/main/java/software/amazon/elasticache/examples/ConnectWithLettuce.java b/java/examples/src/main/java/software/amazon/elasticache/examples/ConnectWithLettuce.java new file mode 100644 index 0000000..9438bef --- /dev/null +++ b/java/examples/src/main/java/software/amazon/elasticache/examples/ConnectWithLettuce.java @@ -0,0 +1,73 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.elasticache.examples; + +import io.lettuce.core.RedisClient; +import io.lettuce.core.RedisCredentials; +import io.lettuce.core.RedisCredentialsProvider; +import io.lettuce.core.RedisURI; +import io.lettuce.core.api.StatefulRedisConnection; +import reactor.core.publisher.Mono; +import software.amazon.awssdk.regions.Region; +import software.amazon.elasticache.auth.ElastiCacheIamAuthTokenProvider; + +/** + * Connects to an ElastiCache serverless cache with Lettuce and IAM authentication. + */ +public final class ConnectWithLettuce { + private static final int DEFAULT_PORT = 6379; + + private ConnectWithLettuce() {} + + /** + * Runs the Lettuce connection example. + * + * @param args cache name, IAM user ID, endpoint, region, and optional port + */ + public static void main(String[] args) { + if (args.length < 4 || args.length > 5) { + throw new IllegalArgumentException( + "Usage: ConnectWithLettuce " + + " [port]"); + } + + String cacheName = args[0]; + String userId = args[1]; + String endpoint = args[2]; + Region region = Region.of(args[3]); + int port = args.length == 5 ? Integer.parseInt(args[4]) : DEFAULT_PORT; + + ElastiCacheIamAuthTokenProvider auth = ElastiCacheIamAuthTokenProvider.builder() + .serverlessCacheName(cacheName) + .userId(userId) + .region(region) + .build(); + + RedisCredentialsProvider credentialsProvider = new RedisCredentialsProvider() { + @Override + public Mono resolveCredentials() { + return Mono.fromSupplier( + () -> RedisCredentials.just(auth.getUserId(), auth.getToken())); + } + }; + RedisURI redisUri = RedisURI.Builder.redis(endpoint, port) + .withSsl(true) + .withAuthentication(credentialsProvider) + .build(); + + RedisClient client = RedisClient.create(redisUri); + StatefulRedisConnection connection = null; + try { + connection = client.connect(); + System.out.println("IAM authentication succeeded; PING returned " + + connection.sync().ping() + "."); + } finally { + if (connection != null) { + connection.close(); + } + client.shutdown(); + } + } +} diff --git a/java/integration-tests/pom.xml b/java/integration-tests/pom.xml new file mode 100644 index 0000000..a6ea3a0 --- /dev/null +++ b/java/integration-tests/pom.xml @@ -0,0 +1,61 @@ + + + + 4.0.0 + + software.amazon.elasticache + developer-toolkit-elasticache-integration-tests + 0.1.0-SNAPSHOT + + + UTF-8 + 8 + 5.13.4 + + + + + software.amazon.elasticache + developer-toolkit-elasticache + 0.1.0-SNAPSHOT + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.1 + + ${maven.compiler.release} + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + true + + + + + diff --git a/java/examples/src/test/java/software/amazon/elasticache/examples/PublicApiTest.java b/java/integration-tests/src/test/java/software/amazon/elasticache/integration/PublicApiTest.java similarity index 95% rename from java/examples/src/test/java/software/amazon/elasticache/examples/PublicApiTest.java rename to java/integration-tests/src/test/java/software/amazon/elasticache/integration/PublicApiTest.java index 4870517..09397e2 100644 --- a/java/examples/src/test/java/software/amazon/elasticache/examples/PublicApiTest.java +++ b/java/integration-tests/src/test/java/software/amazon/elasticache/integration/PublicApiTest.java @@ -2,7 +2,7 @@ * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. * SPDX-License-Identifier: Apache-2.0 */ -package software.amazon.elasticache.examples; +package software.amazon.elasticache.integration; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; diff --git a/java/pom.xml b/java/pom.xml index a53c6cb..353bf6d 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -26,6 +26,7 @@ UTF-8 8 + ["8","11","17","21"] 2.54.10 5.13.4 diff --git a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java index 9f5852e..c774a00 100644 --- a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java +++ b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java @@ -63,7 +63,7 @@ private ElastiCacheIamAuthTokenProvider(Builder builder) { Target target = resolveTarget(builder.serverlessCacheName, builder.replicationGroupId); this.cacheName = target.cacheName; this.serverless = target.serverless; - this.userId = validateAndNormalize("user_id", builder.userId, USER_ID_PATTERN); + this.userId = validateAndNormalize("userId", builder.userId, USER_ID_PATTERN); this.region = resolveRegion( builder.region, builder.awsRegionEnvironmentProvider, builder.regionProvider); this.credentialsProvider = builder.credentialsProvider == null @@ -145,18 +145,18 @@ private static Target resolveTarget(String serverlessCacheName, String replicati boolean hasReplicationGroup = hasText(replicationGroupId); if (hasServerless == hasReplicationGroup) { throw new InvalidParameterException( - "Invalid parameter combination for 'serverless_cache_name' and " - + "'replication_group_id': exactly one must be provided."); + "Invalid parameter combination for 'serverlessCacheName' and " + + "'replicationGroupId': exactly one must be provided."); } if (hasServerless) { return new Target( validateAndNormalize( - "serverless_cache_name", serverlessCacheName, CACHE_NAME_PATTERN), + "serverlessCacheName", serverlessCacheName, CACHE_NAME_PATTERN), true); } return new Target( validateAndNormalize( - "replication_group_id", replicationGroupId, CACHE_NAME_PATTERN), + "replicationGroupId", replicationGroupId, CACHE_NAME_PATTERN), false); } diff --git a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java index 0f1841d..bf18229 100644 --- a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java +++ b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java @@ -315,7 +315,7 @@ void rejectsInvalidCacheNamesWithoutEchoingThem() { InvalidParameterException exception = assertThrows( InvalidParameterException.class, () -> providerBuilder().serverlessCacheName(badName).build()); - assertTrue(exception.getMessage().contains("serverless_cache_name")); + assertTrue(exception.getMessage().contains("serverlessCacheName")); } } @@ -325,7 +325,7 @@ void invalidReplicationGroupIdNamesThatParameter() { InvalidParameterException.class, () -> providerBuilder().replicationGroupId("bad_name!").build()); - assertTrue(exception.getMessage().contains("replication_group_id")); + assertTrue(exception.getMessage().contains("replicationGroupId")); } @Test @@ -342,7 +342,7 @@ void parameterErrorsDoNotEchoHostileValues() { InvalidParameterException.class, () -> providerBuilder().serverlessCacheName(hostile).build()); - assertTrue(exception.getMessage().contains("serverless_cache_name")); + assertTrue(exception.getMessage().contains("serverlessCacheName")); assertFalse(exception.getMessage().contains(hostile)); } @@ -365,7 +365,7 @@ void validatesUserIds() { .serverlessCacheName(CACHE) .userId(userId) .build()); - assertTrue(exception.getMessage().contains("user_id")); + assertTrue(exception.getMessage().contains("userId")); } for (String userId : Arrays.asList( diff --git a/python/README.md b/python/README.md index 31211f1..5f5c67d 100644 --- a/python/README.md +++ b/python/README.md @@ -69,6 +69,10 @@ The chain is checked in this order: 3. Container credentials (Amazon ECS / EKS). 4. EC2 instance profile credentials (IMDS). +Missing credentials are reported when a token is requested. Expired or otherwise +invalid credentials cannot be detected during local signing: token generation can +succeed, but ElastiCache will reject the token when the client connects. + ### Reconnecting clients Clients such as redis-py and valkey-py request credentials on every reconnection. diff --git a/scripts/java_dev.py b/scripts/java_dev.py new file mode 100644 index 0000000..1162329 --- /dev/null +++ b/scripts/java_dev.py @@ -0,0 +1,45 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import argparse +import json +import xml.etree.ElementTree as ET +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +POM = ROOT / "java" / "pom.xml" +MAVEN_NAMESPACE = {"m": "http://maven.apache.org/POM/4.0.0"} + + +def list_supported_versions() -> None: + root = ET.parse(POM).getroot() + value = root.findtext( + "m:properties/m:java.supported.versions", namespaces=MAVEN_NAMESPACE + ) + if value is None: + raise SystemExit("java.supported.versions is missing from java/pom.xml") + + versions = json.loads(value) + if ( + not isinstance(versions, list) + or not versions + or not all(isinstance(version, str) and version for version in versions) + ): + raise SystemExit( + "java.supported.versions must be a non-empty JSON string array" + ) + result = {"all": versions, "min": versions[0], "max": versions[-1]} + print(json.dumps(result, separators=(",", ":"))) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("command", choices=["list-supported-versions"]) + args = parser.parse_args() + + if args.command == "list-supported-versions": + list_supported_versions() + + +if __name__ == "__main__": + main() From 5fd9a6a4aba1d37211f64192960e53bf9ac419c7 Mon Sep 17 00:00:00 2001 From: "Alexandre [C] Luc Rehnby-Martin" Date: Fri, 11 Sep 2026 15:43:15 -0700 Subject: [PATCH 14/40] Keep examples documentation-only --- CHANGELOG.md | 49 ------------------- README.md | 2 - java/CHANGELOG.md | 27 ++++++++++ java/examples/pom.xml | 13 ----- .../examples/ConnectServerlessCacheTest.java | 27 ---------- python/CHANGELOG.md | 24 +++++++++ 6 files changed, 51 insertions(+), 91 deletions(-) delete mode 100644 CHANGELOG.md create mode 100644 java/CHANGELOG.md delete mode 100644 java/examples/src/test/java/software/amazon/elasticache/examples/ConnectServerlessCacheTest.java create mode 100644 python/CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index 8f9e446..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,49 +0,0 @@ -# Changelog - -All notable changes to this project's language packages are documented here. The -format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and each -package uses [Semantic Versioning](https://semver.org/). - -## Java - -### [Unreleased] - -#### Added - -- Initial Java IAM authentication token generator for serverless caches and - node-based replication groups. -- Input validation, lowercase normalization, AWS region and credential resolution, - typed user-correctable errors, and deterministic SigV4 signing tests. -- Credential providers that fail or return unusable credentials are normalized to - an actionable `ConfigurationException`. -- Maven build, Java version CI matrix, documentation, and dependency updates. - -#### Notes - -- Region resolution follows the Java contract: explicit region, then `AWS_REGION`, - then the standard AWS SDK region provider chain. Unlike botocore, the AWS SDK for - Java does not read `AWS_DEFAULT_REGION`. -- Java examples demonstrate Lettuce `RedisCredentialsProvider` integration and the - raw TLS/Redis protocol exchange for custom clients and proxies. The wire-level - example verifies the endpoint hostname during the TLS handshake. - -## Python - -### [1.0.0] - Unreleased - -Initial release. - -#### Added - -- `generate_iam_auth_token()` — signs an IAM authentication token for an Amazon - ElastiCache serverless cache or node-based replication group using the default AWS - credential chain. -- `ElastiCacheIAMAuthTokenProvider` — signs a fresh token on each `get_token()` call, - for clients that re-authenticate on reconnect. -- `developer-toolkit-elasticache generate_iam_auth_token` command-line interface. -- `InvalidParameterError` and `ConfigurationError` (both `ToolkitInputError`) for - invalid arguments and unresolvable region or credentials. Error messages never echo - caller-provided values. -- Examples for redis-py and valkey-py under `examples/`. - -[1.0.0]: https://github.com/aws/developer-toolkit-elasticache/releases/tag/python-v1.0.0 diff --git a/README.md b/README.md index 3156ca0..466ec61 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,6 @@ cache with AWS credentials. | Java | `software.amazon.elasticache:developer-toolkit-elasticache` | [java/README.md](java/README.md) | | Node.js | `@aws/developer-toolkit-elasticache` on npm | [node/README.md](node/README.md) | -See the project [changelog](CHANGELOG.md) for release notes for all language packages. - ## Quick start ### Python diff --git a/java/CHANGELOG.md b/java/CHANGELOG.md new file mode 100644 index 0000000..18230a4 --- /dev/null +++ b/java/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to the Java package will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this package uses [Semantic Versioning](https://semver.org/). + +## [Unreleased] + +### Added + +- Initial Java IAM authentication token generator for serverless caches and + node-based replication groups. +- Input validation, lowercase normalization, AWS region and credential resolution, + typed user-correctable errors, and deterministic SigV4 signing tests. +- Credential providers that fail or return unusable credentials are normalized to + an actionable `ConfigurationException`. +- Maven build, Java version CI matrix, documentation, and dependency updates. + +### Notes + +- Region resolution follows the Java contract: explicit region, then `AWS_REGION`, + then the standard AWS SDK region provider chain. Unlike botocore, the AWS SDK for + Java does not read `AWS_DEFAULT_REGION`. +- Java examples demonstrate Lettuce `RedisCredentialsProvider` integration and the + raw TLS/Redis protocol exchange for custom clients and proxies. The wire-level + example verifies the endpoint hostname during the TLS handshake. diff --git a/java/examples/pom.xml b/java/examples/pom.xml index ba98c0b..05c6ae2 100644 --- a/java/examples/pom.xml +++ b/java/examples/pom.xml @@ -16,7 +16,6 @@ UTF-8 8 7.7.0.RELEASE - 5.13.4 @@ -30,18 +29,6 @@ lettuce-core ${lettuce.version} - - org.junit.jupiter - junit-jupiter-api - ${junit.version} - test - - - org.junit.jupiter - junit-jupiter-engine - ${junit.version} - test - diff --git a/java/examples/src/test/java/software/amazon/elasticache/examples/ConnectServerlessCacheTest.java b/java/examples/src/test/java/software/amazon/elasticache/examples/ConnectServerlessCacheTest.java deleted file mode 100644 index 01ca844..0000000 --- a/java/examples/src/test/java/software/amazon/elasticache/examples/ConnectServerlessCacheTest.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ -package software.amazon.elasticache.examples; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -import java.io.IOException; -import javax.net.ssl.SSLSocket; -import javax.net.ssl.SSLSocketFactory; - -import org.junit.jupiter.api.Test; - -class ConnectServerlessCacheTest { - @Test - void enablesHostnameVerification() throws IOException { - SSLSocketFactory socketFactory = (SSLSocketFactory) SSLSocketFactory.getDefault(); - try (SSLSocket socket = (SSLSocket) socketFactory.createSocket()) { - ConnectServerlessCache.enableHostnameVerification(socket); - - assertEquals( - "HTTPS", - socket.getSSLParameters().getEndpointIdentificationAlgorithm()); - } - } -} diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md new file mode 100644 index 0000000..3b93e89 --- /dev/null +++ b/python/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +All notable changes to the Python 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] - Unreleased + +Initial release. + +### Added + +- `generate_iam_auth_token()` — signs an IAM authentication token for an Amazon + ElastiCache serverless cache or node-based replication group using the default AWS + credential chain. +- `ElastiCacheIAMAuthTokenProvider` — signs a fresh token on each `get_token()` call, + for clients that re-authenticate on reconnect. +- `developer-toolkit-elasticache generate_iam_auth_token` command-line interface. +- `InvalidParameterError` and `ConfigurationError` (both `ToolkitInputError`) for + invalid arguments and unresolvable region or credentials. Error messages never echo + caller-provided values. +- Examples for redis-py and valkey-py under `examples/`. + +[1.0.0]: https://github.com/aws/developer-toolkit-elasticache/releases/tag/python-v1.0.0 From e6c14ef8c0d2bcc7130f484be1671b3cccba11ed Mon Sep 17 00:00:00 2001 From: "Alexandre [C] Luc Rehnby-Martin" Date: Wed, 16 Sep 2026 15:53:53 -0700 Subject: [PATCH 15/40] feat(java): add token auto-refresh --- README.md | 2 +- java/CHANGELOG.md | 5 + java/README.md | 66 +- .../examples/ConnectWithLettuce.java | 13 +- .../integration/PublicApiTest.java | 4 + .../auth/ElastiCacheIamAuthTokenManager.java | 604 ++++++++++++++++++ .../auth/ElastiCacheIamAuthTokenProvider.java | 2 +- .../auth/TokenRefreshException.java | 35 + .../amazon/elasticache/auth/package-info.java | 2 +- .../ElastiCacheIamAuthTokenManagerTest.java | 452 +++++++++++++ 10 files changed, 1174 insertions(+), 11 deletions(-) create mode 100644 java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java create mode 100644 java/src/main/java/software/amazon/elasticache/auth/TokenRefreshException.java create mode 100644 java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java diff --git a/README.md b/README.md index 466ec61..ff71530 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ String password = auth.getToken(); ``` See the [Java README](java/README.md) for Maven setup, region and credential -resolution, and a standalone TLS connection example. +resolution, token caching and background refresh, and connection examples. ### Node.js diff --git a/java/CHANGELOG.md b/java/CHANGELOG.md index 18230a4..f92b138 100644 --- a/java/CHANGELOG.md +++ b/java/CHANGELOG.md @@ -15,6 +15,11 @@ and this package uses [Semantic Versioning](https://semver.org/). typed user-correctable errors, and deterministic SigV4 signing tests. - Credential providers that fail or return unusable credentials are normalized to an actionable `ConfigurationException`. +- `ElastiCacheIamAuthTokenManager` for caching tokens, refreshing them in the + background before expiry, retrying transient failures, and notifying a + token-change callback. +- `TokenRefreshException` for closed managers and refreshes that cannot replace + an expired token. - Maven build, Java version CI matrix, documentation, and dependency updates. ### Notes diff --git a/java/README.md b/java/README.md index e2321e4..4b59d40 100644 --- a/java/README.md +++ b/java/README.md @@ -2,7 +2,8 @@ This Java library generates the IAM authentication token that Amazon ElastiCache requires as the connection password for an IAM-enabled user. It supports both -serverless caches and node-based replication groups. +serverless caches and node-based replication groups, including optional token +caching and background refresh. ## Installation @@ -69,18 +70,75 @@ 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. +## Caching and background refresh + +`ElastiCacheIamAuthTokenProvider` is stateless and signs a new token on every +`getToken()` call. `ElastiCacheIamAuthTokenManager` caches a token and refreshes +it in the background: + +| | `ElastiCacheIamAuthTokenProvider` | `ElastiCacheIamAuthTokenManager` | +|---|---|---| +| Token per `getToken()` | Freshly signed every call | Cached while valid | +| Refresh | Caller-driven | Background after `refreshAfter` | +| Refresh failures | Surfaced to the caller | Retried while the current token is valid | +| Change notification | None | `onTokenChanged` | +| Cleanup | None needed | `close()` | + +```java +import java.time.Duration; +import software.amazon.awssdk.regions.Region; +import software.amazon.elasticache.auth.ElastiCacheIamAuthTokenManager; + +try (ElastiCacheIamAuthTokenManager auth = + ElastiCacheIamAuthTokenManager.builder() + .serverlessCacheName("my-cache") + .userId("iam-user") + .region(Region.US_EAST_1) + .refreshAfter(Duration.ofMinutes(5)) + .onTokenChanged(token -> + System.out.println("Installed a new IAM auth token")) + .build()) { + ElastiCacheIamAuthTokenManager.Credentials credentials = auth.getCredentials(); + String username = credentials.getUserId(); + String password = credentials.getToken(); +} +``` + +The first token is generated lazily. Concurrent callers share one initial token +generation, and subsequent calls return the cached token while it is valid. +Refresh starts after 5 minutes by default; `refreshAfter(...)` accepts values from +1 millisecond up to, but not including, the 15-minute token lifetime. + +A refresh makes up to 8 attempts with jittered exponential backoff capped at 5 +seconds. If refresh fails while the cached token is still valid, callers continue +to receive that token and another refresh is scheduled. If the token expires +before it can be replaced, `getToken()` throws `TokenRefreshException` with the +last refresh failure as its cause. + +The refresh thread is a daemon and does not keep the JVM alive. Close the manager +to cancel background work; token requests after close throw +`TokenRefreshException`. + +Use the manager when a client reconnects often or requests credentials more +frequently than the token lifetime. Use the provider when every request should +sign a new token. + ## Reconnecting clients Clients should request fresh credentials whenever they connect or reconnect. The Lettuce example implements `RedisCredentialsProvider` so each credentials request -calls `getToken()`: +uses the manager's current token: ```java RedisCredentialsProvider credentialsProvider = new RedisCredentialsProvider() { @Override public Mono resolveCredentials() { - return Mono.fromSupplier( - () -> RedisCredentials.just(auth.getUserId(), auth.getToken())); + return Mono.fromSupplier(() -> { + ElastiCacheIamAuthTokenManager.Credentials credentials = + auth.getCredentials(); + return RedisCredentials.just( + credentials.getUserId(), credentials.getToken()); + }); } }; ``` 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 index 9438bef..b2f26a4 100644 --- a/java/examples/src/main/java/software/amazon/elasticache/examples/ConnectWithLettuce.java +++ b/java/examples/src/main/java/software/amazon/elasticache/examples/ConnectWithLettuce.java @@ -11,7 +11,7 @@ import io.lettuce.core.api.StatefulRedisConnection; import reactor.core.publisher.Mono; import software.amazon.awssdk.regions.Region; -import software.amazon.elasticache.auth.ElastiCacheIamAuthTokenProvider; +import software.amazon.elasticache.auth.ElastiCacheIamAuthTokenManager; /** * Connects to an ElastiCache serverless cache with Lettuce and IAM authentication. @@ -39,7 +39,7 @@ public static void main(String[] args) { Region region = Region.of(args[3]); int port = args.length == 5 ? Integer.parseInt(args[4]) : DEFAULT_PORT; - ElastiCacheIamAuthTokenProvider auth = ElastiCacheIamAuthTokenProvider.builder() + ElastiCacheIamAuthTokenManager auth = ElastiCacheIamAuthTokenManager.builder() .serverlessCacheName(cacheName) .userId(userId) .region(region) @@ -48,8 +48,12 @@ public static void main(String[] args) { RedisCredentialsProvider credentialsProvider = new RedisCredentialsProvider() { @Override public Mono resolveCredentials() { - return Mono.fromSupplier( - () -> RedisCredentials.just(auth.getUserId(), auth.getToken())); + return Mono.fromSupplier(() -> { + ElastiCacheIamAuthTokenManager.Credentials credentials = + auth.getCredentials(); + return RedisCredentials.just( + credentials.getUserId(), credentials.getToken()); + }); } }; RedisURI redisUri = RedisURI.Builder.redis(endpoint, port) @@ -68,6 +72,7 @@ public Mono resolveCredentials() { connection.close(); } client.shutdown(); + auth.close(); } } } 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 index 09397e2..d2e7bcb 100644 --- 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 @@ -10,16 +10,20 @@ import org.junit.jupiter.api.Test; 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()); 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/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..0fd0680 --- /dev/null +++ b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java @@ -0,0 +1,604 @@ +/* + * 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.regions.Region; +import software.amazon.awssdk.regions.providers.AwsRegionProvider; + +/** + * 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 String CLOSED_MESSAGE = "The token manager is 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 CachedToken cached; + private CompletableFuture inFlight; + private Cancellable refreshTask; + private Cancellable retryTask; + 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 + ? software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider + .builder() + .build() + : builder.credentialsProvider) + .awsRegionEnvironmentProvider(builder.awsRegionEnvironmentProvider == null + ? () -> System.getenv("AWS_REGION") + : builder.awsRegionEnvironmentProvider) + .regionProvider(builder.regionProvider == null + ? software.amazon.awssdk.regions.providers + .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; + 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) { + scheduleRefreshLocked(0); + } + return token; + } + } + 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(CLOSED_MESSAGE)); + } + if (ownsScheduler) { + scheduler.close(); + } + } + + private CompletableFuture refresh() { + CompletableFuture refresh; + synchronized (lock) { + ensureOpen(); + if (inFlight != null) { + 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( + CLOSED_MESSAGE, lastError)); + return; + } + retryTask = null; + } + + try { + long issuedAt = currentTimeMillis.getAsLong(); + String token = provider.getToken(); + synchronized (lock) { + if (closed) { + refresh.completeExceptionally(new TokenRefreshException(CLOSED_MESSAGE)); + return; + } + } + install(token, issuedAt); + refresh.complete(token); + } catch (RuntimeException exception) { + synchronized (lock) { + if (closed) { + refresh.completeExceptionally( + new TokenRefreshException(CLOSED_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(CLOSED_MESSAGE, lastError)); + } else { + retryTask = task; + } + } + } catch (RejectedExecutionException exception) { + refresh.completeExceptionally( + new TokenRefreshException(CLOSED_MESSAGE, lastError)); + } + } + + private void finishFailedRefresh( + CompletableFuture refresh, RuntimeException lastError) { + String validToken = null; + synchronized (lock) { + long now = currentTimeMillis.getAsLong(); + if (cached != null && now < cached.expiresAt) { + validToken = cached.token; + scheduleRefreshLocked(MAX_DELAY_MILLIS); + } + } + if (validToken != null) { + refresh.complete(validToken); + return; + } + synchronized (lock) { + if (cached == null) { + refresh.completeExceptionally(lastError); + } else { + refresh.completeExceptionally( + new TokenRefreshException(REFRESH_FAILED_MESSAGE, lastError)); + } + } + } + + private void install(String token, long issuedAt) { + synchronized (lock) { + cached = new CachedToken( + token, + issuedAt + refreshAfterMillis, + issuedAt + ElastiCacheIamAuthTokenProvider.TOKEN_TTL.toMillis()); + scheduleRefreshLocked(refreshAfterMillis); + } + notifyTokenChanged(token); + } + + private void notifyTokenChanged(String token) { + if (onTokenChanged == null) { + return; + } + try { + onTokenChanged.accept(token); + } catch (Throwable ignored) { + // A consumer callback cannot invalidate a successfully 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; + } + 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(ElastiCacheIamAuthTokenProvider.TOKEN_TTL) >= 0) { + throw new InvalidParameterException( + "Invalid value for parameter 'refreshAfter': must be at least " + + "1 millisecond and less than 15 minutes."); + } + 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 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 15-minute token lifetime. + * + * @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 after each newly generated token is installed. + * + *

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; + } + + /** + * 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 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 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 index c774a00..d2f1d1b 100644 --- a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java +++ b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java @@ -39,7 +39,7 @@ public final class ElastiCacheIamAuthTokenProvider { private static final String URL_SCHEME_PREFIX = "https://"; private static final String SERVICE_NAME = "elasticache"; private static final String AWS_REGION_ENV_VAR = "AWS_REGION"; - private static final Duration TOKEN_TTL = Duration.ofSeconds(900); + 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 = 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/package-info.java b/java/src/main/java/software/amazon/elasticache/auth/package-info.java index 25886ab..a681517 100644 --- a/java/src/main/java/software/amazon/elasticache/auth/package-info.java +++ b/java/src/main/java/software/amazon/elasticache/auth/package-info.java @@ -4,6 +4,6 @@ */ /** - * IAM authentication token generation for Amazon ElastiCache. + * 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..525ea6d --- /dev/null +++ b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java @@ -0,0 +1,452 @@ +/* + * 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.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.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 sharesInitialTokenGenerationAcrossConcurrentCallers() throws InterruptedException { + TestScheduler scheduler = new TestScheduler(); + AtomicInteger credentialCalls = new AtomicInteger(); + CountDownLatch entered = 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 { + release.await(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(exception); + } + return FIRST_CREDENTIALS; + }) + .signingClock(SIGNING_CLOCK) + .currentTimeMillis(scheduler::now) + .scheduler(scheduler) + .build(); + try { + CompletableFuture first = + CompletableFuture.supplyAsync(manager::getToken); + entered.await(); + CompletableFuture second = + CompletableFuture.supplyAsync(manager::getToken); + 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( + java.util.Arrays.asList(100L, 200L, 400L, 800L, 1_600L, 3_200L, 5_000L), + harness.scheduler.delays()); + harness.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); + assertEquals(token, harness.manager.getToken()); + assertEquals(9, harness.credentialCalls.get()); + + harness.failing.set(false); + harness.scheduler.advance(0); + assertNotEquals(token, harness.manager.getToken()); + } finally { + harness.manager.close(); + } + } + + @Test + void throwsRefreshExceptionAfterCachedTokenExpires() { + Harness harness = new Harness(Duration.ofSeconds(899), null); + try { + harness.manager.getToken(); + harness.failing.set(true); + harness.scheduler.advance(911_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 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); + } + + @Test + void closePreventsRetryWhenInFlightGenerationFails() throws InterruptedException { + 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(); + try { + release.await(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } + 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; + } + }); + entered.await(); + manager.close(); + release.countDown(); + + assertTrue(result.join() instanceof TokenRefreshException); + assertEquals(0, scheduler.createdCount()); + } + + @Test + void validatesRefreshInterval() { + for (Duration value : java.util.Arrays.asList( + Duration.ZERO, + Duration.ofMillis(-1), + Duration.ofNanos(1), + Duration.ofMinutes(15), + Duration.ofMinutes(16))) { + assertThrows( + InvalidParameterException.class, + () -> baseBuilder(new TestScheduler(), new AtomicInteger(), new AtomicBoolean()) + .refreshAfter(value) + .build()); + } + } + + @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) { + for (int attempt = 0; attempt < 10_000 + && scheduler.createdCount() < expected; attempt++) { + Thread.yield(); + } + assertEquals(expected, scheduler.createdCount()); + } + + 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); + return scheduled; + } + + @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 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(); + } + } + } +} From 233e23c34d46fe596abc947f3cde69c518bea966 Mon Sep 17 00:00:00 2001 From: "Alexandre [C] Luc Rehnby-Martin" Date: Fri, 18 Sep 2026 10:32:52 -0700 Subject: [PATCH 16/40] Handle fatal token refresh failures --- .../auth/ElastiCacheIamAuthTokenManager.java | 9 ++- .../ElastiCacheIamAuthTokenManagerTest.java | 66 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java index 0fd0680..840b076 100644 --- a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java +++ b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java @@ -226,7 +226,14 @@ private void runRefreshAttempt( } install(token, issuedAt); refresh.complete(token); - } catch (RuntimeException exception) { + } 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( diff --git a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java index 525ea6d..30a666a 100644 --- a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java +++ b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java @@ -178,6 +178,72 @@ void retriesInitialRefreshWithCappedExponentialBackoff() { 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 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(); From db4f2d945c7bfb751dd3a5e146c43d75dddaaef5 Mon Sep 17 00:00:00 2001 From: "Alexandre [C] Luc Rehnby-Martin" Date: Fri, 18 Sep 2026 11:41:21 -0700 Subject: [PATCH 17/40] Address token manager concurrency review --- java/README.md | 5 +- .../auth/ElastiCacheIamAuthTokenManager.java | 111 +++++++--- .../ElastiCacheIamAuthTokenManagerTest.java | 204 ++++++++++++++++-- 3 files changed, 272 insertions(+), 48 deletions(-) diff --git a/java/README.md b/java/README.md index 4b59d40..044fa66 100644 --- a/java/README.md +++ b/java/README.md @@ -107,7 +107,10 @@ try (ElastiCacheIamAuthTokenManager auth = The first token is generated lazily. Concurrent callers share one initial token generation, and subsequent calls return the cached token while it is valid. Refresh starts after 5 minutes by default; `refreshAfter(...)` accepts values from -1 millisecond up to, but not including, the 15-minute token lifetime. +1 millisecond up to, but not including, 14 minutes 40 seconds. The manager reserves +the final 20 seconds of the service's 15-minute token lifetime for clock skew and +in-transit reconnects. Token-change callbacks run asynchronously after the new +token is available to callers. A refresh makes up to 8 attempts with jittered exponential backoff capped at 5 seconds. If refresh fails while the cached token is still valid, callers continue diff --git a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java index 840b076..f645987 100644 --- a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java +++ b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java @@ -9,7 +9,9 @@ import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.concurrent.Executor; import java.util.concurrent.Executors; +import java.util.concurrent.ForkJoinPool; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -21,8 +23,10 @@ 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. @@ -39,7 +43,12 @@ public final class ElastiCacheIamAuthTokenManager implements AutoCloseable { 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."; @@ -48,15 +57,19 @@ public final class ElastiCacheIamAuthTokenManager implements AutoCloseable { private final ElastiCacheIamAuthTokenProvider provider; private final long refreshAfterMillis; private final Consumer onTokenChanged; + private final Executor callbackExecutor; 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) { @@ -66,16 +79,13 @@ private ElastiCacheIamAuthTokenManager(Builder builder) { .serverlessCacheName(builder.serverlessCacheName) .replicationGroupId(builder.replicationGroupId) .credentialsProvider(builder.credentialsProvider == null - ? software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider - .builder() - .build() + ? DefaultCredentialsProvider.builder().build() : builder.credentialsProvider) .awsRegionEnvironmentProvider(builder.awsRegionEnvironmentProvider == null ? () -> System.getenv("AWS_REGION") : builder.awsRegionEnvironmentProvider) .regionProvider(builder.regionProvider == null - ? software.amazon.awssdk.regions.providers - .DefaultAwsRegionProviderChain.builder().build() + ? DefaultAwsRegionProviderChain.builder().build() : builder.regionProvider) .signingClock(builder.signingClock == null ? Clock.systemUTC() @@ -83,10 +93,15 @@ private ElastiCacheIamAuthTokenManager(Builder builder) { .build(); this.refreshAfterMillis = resolveRefreshAfterMillis(builder.refreshAfter); this.onTokenChanged = builder.onTokenChanged; + this.callbackExecutor = builder.callbackExecutor == null + ? ForkJoinPool.commonPool() + : builder.callbackExecutor; 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; @@ -139,7 +154,7 @@ public String getToken() { long now = currentTimeMillis.getAsLong(); if (cached != null && now < cached.expiresAt) { String token = cached.token; - if (now >= cached.refreshAt) { + if (now >= cached.refreshAt && now >= retryNotBefore) { scheduleRefreshLocked(0); } return token; @@ -168,7 +183,8 @@ public void close() { pending = inFlight; } if (pending != null) { - pending.completeExceptionally(new TokenRefreshException(CLOSED_MESSAGE)); + pending.completeExceptionally( + new TokenRefreshException(REFRESH_CANCELLED_MESSAGE)); } if (ownsScheduler) { scheduler.close(); @@ -180,6 +196,9 @@ private CompletableFuture refresh() { synchronized (lock) { ensureOpen(); if (inFlight != null) { + if (onSharedRefresh != null) { + onSharedRefresh.run(); + } return inFlight; } refresh = new CompletableFuture<>(); @@ -209,7 +228,7 @@ private void runRefreshAttempt( synchronized (lock) { if (closed) { refresh.completeExceptionally(new TokenRefreshException( - CLOSED_MESSAGE, lastError)); + REFRESH_CANCELLED_MESSAGE, lastError)); return; } retryTask = null; @@ -218,14 +237,10 @@ private void runRefreshAttempt( try { long issuedAt = currentTimeMillis.getAsLong(); String token = provider.getToken(); - synchronized (lock) { - if (closed) { - refresh.completeExceptionally(new TokenRefreshException(CLOSED_MESSAGE)); - return; - } + if (beforeInstall != null) { + beforeInstall.run(); } - install(token, issuedAt); - refresh.complete(token); + installAndComplete(refresh, token, issuedAt); } catch (Throwable failure) { if (failure instanceof Error) { refresh.completeExceptionally(failure); @@ -237,7 +252,8 @@ private void runRefreshAttempt( synchronized (lock) { if (closed) { refresh.completeExceptionally( - new TokenRefreshException(CLOSED_MESSAGE, exception)); + new TokenRefreshException( + REFRESH_CANCELLED_MESSAGE, exception)); return; } } @@ -261,14 +277,15 @@ private void scheduleRetry( if (closed) { task.cancel(); refresh.completeExceptionally( - new TokenRefreshException(CLOSED_MESSAGE, lastError)); + new TokenRefreshException( + REFRESH_CANCELLED_MESSAGE, lastError)); } else { retryTask = task; } } } catch (RejectedExecutionException exception) { refresh.completeExceptionally( - new TokenRefreshException(CLOSED_MESSAGE, lastError)); + new TokenRefreshException(REFRESH_CANCELLED_MESSAGE, lastError)); } } @@ -279,6 +296,7 @@ private void finishFailedRefresh( long now = currentTimeMillis.getAsLong(); if (cached != null && now < cached.expiresAt) { validToken = cached.token; + retryNotBefore = now + MAX_DELAY_MILLIS; scheduleRefreshLocked(MAX_DELAY_MILLIS); } } @@ -296,15 +314,23 @@ private void finishFailedRefresh( } } - private void install(String token, long issuedAt) { + 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 + ElastiCacheIamAuthTokenProvider.TOKEN_TTL.toMillis()); + issuedAt + EFFECTIVE_TOKEN_LIFETIME.toMillis()); + retryNotBefore = 0; scheduleRefreshLocked(refreshAfterMillis); + refresh.complete(token); + notifyTokenChanged(token); } - notifyTokenChanged(token); } private void notifyTokenChanged(String token) { @@ -312,9 +338,15 @@ private void notifyTokenChanged(String token) { return; } try { - onTokenChanged.accept(token); + callbackExecutor.execute(() -> { + try { + onTokenChanged.accept(token); + } catch (Throwable ignored) { + // A consumer callback cannot invalidate a successfully signed token. + } + }); } catch (Throwable ignored) { - // A consumer callback cannot invalidate a successfully signed token. + // Failure to dispatch a callback cannot invalidate a signed token. } } @@ -363,6 +395,9 @@ private static String await(CompletableFuture future) { if (cause instanceof RuntimeException) { throw (RuntimeException) cause; } + if (cause instanceof Error) { + throw (Error) cause; + } throw exception; } } @@ -378,10 +413,10 @@ private static long resolveRefreshAfterMillis(Duration refreshAfter) { if (resolved.isNegative() || resolved.isZero() || millis <= 0 - || resolved.compareTo(ElastiCacheIamAuthTokenProvider.TOKEN_TTL) >= 0) { + || resolved.compareTo(EFFECTIVE_TOKEN_LIFETIME) >= 0) { throw new InvalidParameterException( "Invalid value for parameter 'refreshAfter': must be at least " - + "1 millisecond and less than 15 minutes."); + + "1 millisecond and less than 14 minutes 40 seconds."); } return millis; } @@ -428,12 +463,15 @@ public static final class Builder { private AwsCredentialsProvider credentialsProvider; private Duration refreshAfter; private Consumer onTokenChanged; + private Executor callbackExecutor; 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() {} @@ -497,7 +535,8 @@ public Builder credentialsProvider(AwsCredentialsProvider credentialsProvider) { * 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 15-minute token lifetime. + * 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 @@ -508,7 +547,8 @@ public Builder refreshAfter(Duration refreshAfter) { } /** - * Sets a callback invoked after each newly generated token is installed. + * Sets a callback invoked asynchronously after each newly generated token + * is installed and made available to token callers. * *

Callback failures are ignored and do not discard the new token. * @@ -551,6 +591,23 @@ Builder scheduler(Scheduler scheduler) { return this; } + Builder callbackExecutor(Executor callbackExecutor) { + this.callbackExecutor = + Objects.requireNonNull(callbackExecutor, "callbackExecutor"); + 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. * diff --git a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java index 30a666a..21cabf1 100644 --- a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java +++ b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java @@ -7,6 +7,7 @@ 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; @@ -19,6 +20,9 @@ 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; @@ -114,10 +118,11 @@ void returnsNormalizedCredentialsAndCachedToken() { } @Test - void sharesInitialTokenGenerationAcrossConcurrentCallers() throws InterruptedException { + 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) @@ -127,7 +132,9 @@ void sharesInitialTokenGenerationAcrossConcurrentCallers() throws InterruptedExc credentialCalls.incrementAndGet(); entered.countDown(); try { - release.await(); + 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); @@ -137,13 +144,15 @@ void sharesInitialTokenGenerationAcrossConcurrentCallers() throws InterruptedExc .signingClock(SIGNING_CLOCK) .currentTimeMillis(scheduler::now) .scheduler(scheduler) + .onSharedRefresh(shared::countDown) .build(); try { CompletableFuture first = CompletableFuture.supplyAsync(manager::getToken); - entered.await(); + await(entered); CompletableFuture second = CompletableFuture.supplyAsync(manager::getToken); + await(shared); release.countDown(); assertEquals(first.join(), second.join()); @@ -208,6 +217,51 @@ void initialErrorClearsInFlightAndAllowsRetry() { } } + @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(); @@ -253,11 +307,14 @@ void servesValidTokenWhileRefreshFailsAndRecovers() { 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(0); + harness.scheduler.advance(5_000); assertNotEquals(token, harness.manager.getToken()); } finally { harness.manager.close(); @@ -265,12 +322,14 @@ void servesValidTokenWhileRefreshFailsAndRecovers() { } @Test - void throwsRefreshExceptionAfterCachedTokenExpires() { - Harness harness = new Harness(Duration.ofSeconds(899), null); + void treatsTokenAsExpiredAtEffectiveLifetime() { + Harness harness = new Harness(Duration.ofSeconds(879), null); try { harness.manager.getToken(); harness.failing.set(true); - harness.scheduler.advance(911_000); + // 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 { @@ -307,6 +366,37 @@ void ignoresTokenChangedCallbackFailures() { } } + @Test + void blockingTokenChangedCallbackDoesNotDelayTokenCaller() throws Exception { + TestScheduler scheduler = new TestScheduler(); + CountDownLatch callbackEntered = new CountDownLatch(1); + CountDownLatch releaseCallback = new CountDownLatch(1); + ExecutorService callbackExecutor = Executors.newSingleThreadExecutor(); + ElastiCacheIamAuthTokenManager manager = + baseBuilder(scheduler, new AtomicInteger(), new AtomicBoolean()) + .callbackExecutor(callbackExecutor) + .onTokenChanged(token -> { + 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()); + } finally { + releaseCallback.countDown(); + manager.close(); + callbackExecutor.shutdownNow(); + } + } + @Test void closeCancelsBackgroundWorkAndRejectsRequests() { Harness harness = new Harness(); @@ -319,7 +409,7 @@ void closeCancelsBackgroundWorkAndRejectsRequests() { } @Test - void closePreventsRetryWhenInFlightGenerationFails() throws InterruptedException { + void closePreventsRetryWhenInFlightGenerationFails() { TestScheduler scheduler = new TestScheduler(); CountDownLatch entered = new CountDownLatch(1); CountDownLatch release = new CountDownLatch(1); @@ -329,11 +419,7 @@ void closePreventsRetryWhenInFlightGenerationFails() throws InterruptedException .region(Region.US_EAST_1) .credentialsProvider(() -> { entered.countDown(); - try { - release.await(); - } catch (InterruptedException exception) { - Thread.currentThread().interrupt(); - } + await(release); throw new IllegalStateException("credential chain failed"); }) .signingClock(SIGNING_CLOCK) @@ -349,11 +435,46 @@ void closePreventsRetryWhenInFlightGenerationFails() throws InterruptedException return exception; } }); - entered.await(); + await(entered); manager.close(); release.countDown(); - assertTrue(result.join() instanceof TokenRefreshException); + 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()) + .callbackExecutor(Runnable::run) + .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()); } @@ -363,6 +484,8 @@ void validatesRefreshInterval() { Duration.ZERO, Duration.ofMillis(-1), Duration.ofNanos(1), + Duration.ofSeconds(880), + Duration.ofSeconds(899), Duration.ofMinutes(15), Duration.ofMinutes(16))) { assertThrows( @@ -371,6 +494,12 @@ void validatesRefreshInterval() { .refreshAfter(value) .build()); } + + ElastiCacheIamAuthTokenManager manager = + baseBuilder(new TestScheduler(), new AtomicInteger(), new AtomicBoolean()) + .refreshAfter(Duration.ofSeconds(879)) + .build(); + manager.close(); } @Test @@ -396,17 +525,35 @@ private static ElastiCacheIamAuthTokenManager.Builder baseBuilder( .signingClock(SIGNING_CLOCK) .currentTimeMillis(scheduler::now) .random(() -> 0.5) - .scheduler(scheduler); + .scheduler(scheduler) + .callbackExecutor(Runnable::run); } private static void awaitCreatedTasks(TestScheduler scheduler, int expected) { - for (int attempt = 0; attempt < 10_000 - && scheduler.createdCount() < expected; attempt++) { - Thread.yield(); - } + 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(); @@ -451,6 +598,7 @@ public synchronized ElastiCacheIamAuthTokenManager.Cancellable schedule( TestTask scheduled = new TestTask(time + delayMillis, delayMillis, task); pending.add(scheduled); created.add(scheduled); + notifyAll(); return scheduled; } @@ -489,6 +637,22 @@ private synchronized TestTask lastCreated() { 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 From a5c6810696db43db4c80b0311401521228a64ba7 Mon Sep 17 00:00:00 2001 From: mwaisaida-13 Date: Mon, 21 Sep 2026 12:38:56 +0100 Subject: [PATCH 18/40] Add ElastiCacheIAMAuthTokenManager: cached, background-refreshed IAM auth tokens The stateless ElastiCacheIAMAuthTokenProvider signs on every call. The manager wraps it with a thread-safe cache and refreshes the token on a background daemon thread `refresh_after` seconds after signing (default 300), so clients that reconnect often or run connection pools always get a valid token without signing per call. Behaviour - Config validated and region resolved eagerly at construction; first token signed lazily on the first get_token()/get_credentials(). - A token is served for 880s after signing: the 900s lifetime minus a 20s margin for clock skew and the AUTH round trip. refresh_after must be below 880. Expiry is tracked on the wall clock, since the server judges X-Amz-Date against its own wall clock and a monotonic clock stands still through a host suspend. - get_token() returns the cached token while it is valid. A token found past its refresh due time (missed timer, suspended process) is returned immediately and refreshed in the background; callers holding a valid token never wait. - Single flight: concurrent callers with no valid token share one signing cycle and receive the same result or the same failure (concurrent.futures.Future). - Retry: up to 8 attempts, exponential backoff from 100ms with a nominal 5s cap, +/-20% jitter. Every error is retried, including ConfigurationError: on an instance role a transient IMDS failure surfaces as "no credentials". - Failure semantics: while the cached token is valid a failed cycle is invisible to callers, logs a warning, and schedules another cycle after the maximum backoff (get_token() does not restart it early); with no token the last error is raised; with an expired token TokenRefreshError is raised with the last error as __cause__. - on_token_changed(token) is called synchronously outside the lock for each newly installed token; a callback failure is logged and the token kept. - close() (or the context manager) cancels the timer, wakes a sleeping retry, drops the cached secret, and makes later calls raise TokenRefreshError. Idempotent. A scheduling failure never loses a signed token. Also - TokenRefreshError (not a ToolkitInputError) added to errors and exported with the manager and DEFAULT_REFRESH_AFTER_SECONDS. - _CachedToken keeps the bearer token out of its repr. - Tests: shared session fake and fixtures moved to tests/helpers.py and tests/conftest.py; 53 manager tests with injected clock, scheduler, sleep and jitter so no real timers run, plus real-thread single-flight tests. - README "Caching and background refresh" section and CHANGELOG entry. --- python/CHANGELOG.md | 17 + python/README.md | 63 ++ .../developer_toolkit_elasticache/__init__.py | 8 + .../developer_toolkit_elasticache/errors.py | 10 + .../token_manager.py | 335 ++++++++ python/tests/conftest.py | 23 + python/tests/helpers.py | 48 ++ python/tests/test_token_generator.py | 90 +- python/tests/test_token_manager.py | 780 ++++++++++++++++++ 9 files changed, 1302 insertions(+), 72 deletions(-) create mode 100644 python/src/developer_toolkit_elasticache/token_manager.py create mode 100644 python/tests/conftest.py create mode 100644 python/tests/helpers.py create mode 100644 python/tests/test_token_manager.py diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 3b93e89..e12e427 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -4,6 +4,23 @@ 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/). +## [Unreleased] + +### Added + +- `ElastiCacheIAMAuthTokenManager` — caches a token for 880 seconds (its lifetime + minus a 900 - 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)`; `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. + ## [1.0.0] - Unreleased Initial release. diff --git a/python/README.md b/python/README.md index 31211f1..228d440 100644 --- a/python/README.md +++ b/python/README.md @@ -88,6 +88,69 @@ 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. + +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/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_manager.py b/python/src/developer_toolkit_elasticache/token_manager.py new file mode 100644 index 0000000..c828b36 --- /dev/null +++ b/python/src/developer_toolkit_elasticache/token_manager.py @@ -0,0 +1,335 @@ +# 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 Protocol + +from botocore.session import Session + +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." +) + +# 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 ignored. + + 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, + 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, + ) + 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_if_issued_after=-math.inf) + + 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_if_issued_after: float) -> str: + """Sign a new token, or reuse one issued after ``reuse_if_issued_after``. + + 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.issued_at > reuse_if_issued_after + 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 = self._run_refresh_cycle() + except BaseException as error: + flight.set_exception(error) + raise + else: + flight.set_result(token) + return token + finally: + with self._lock: + if self._inflight is flight: + self._inflight = None + + def _run_refresh_cycle(self) -> str: + """Sign with retries. + + 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) + self._notify(token) + return token + + 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, + ) + return cached.token + 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: # a callback failure must not discard the token + _logger.warning("on_token_changed callback raised", exc_info=True) + + 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: + self._cached = _CachedToken( + token=token, + issued_at=issued_at, + expires_at=issued_at + _EFFECTIVE_LIFETIME_SECONDS, + ) + self._schedule_locked(self._refresh_after, self._cached) + + 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_if_issued_after=due.issued_at) + 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..849b48e 100644 --- a/python/tests/test_token_generator.py +++ b/python/tests/test_token_generator.py @@ -6,7 +6,14 @@ import pytest from botocore.credentials import Credentials -from botocore.session import Session +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): diff --git a/python/tests/test_token_manager.py b/python/tests/test_token_manager.py new file mode 100644 index 0000000..4295794 --- /dev/null +++ b/python/tests/test_token_manager.py @@ -0,0 +1,780 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import threading +import time + +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): + """Start ``count`` threads that all enter ``target`` at once; return their results.""" + 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) for _ in range(count)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(timeout=5) + assert len(results) == count + return results + + +# --- 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): + session = fake_session() + credentials = session.get_credentials.return_value + + def slow_get_credentials(): + # Hold the flight open long enough for every worker to join it. + time.sleep(0.05) + return credentials + + session.get_credentials.side_effect = slow_get_credentials + manager = _manager(session) + + tokens = _run_concurrently(5, manager.get_token) + + assert all(isinstance(token, str) for token in tokens) + assert session.get_credentials.call_count == 1 + + +def test_concurrent_callers_share_one_failure(clock, sleep): + # 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) + sleep.on_sleep = lambda: time.sleep(0.005) + manager = _manager(session) + + outcomes = _run_concurrently(4, manager.get_token) + + 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_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) + + +# --- on_token_changed -------------------------------------------------------------- + + +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 = manager.get_token() + + 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 "consumer bug" 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_returns_early_when_stopped(): + stop = threading.Event() + stop.set() + started = time.monotonic() + + REAL_WAIT(stop, 60) + + assert time.monotonic() - started < 1 From c5462a64f85fb4d67a89f79919386a657479f800 Mon Sep 17 00:00:00 2001 From: Edward Liang <76571219+edlng@users.noreply.github.com> Date: Mon, 21 Sep 2026 09:26:06 -0700 Subject: [PATCH 19/40] feat(node): add auto-refresh (#12) Signed-off-by: Edward Liang --- node/CHANGELOG.md | 7 +- node/README.md | 109 ++++- node/examples/connect-iovalkey.mjs | 58 ++- node/examples/connect-node-redis.mjs | 56 +++ node/src/errors.ts | 12 + node/src/index.ts | 8 + node/src/token-generator.ts | 17 +- node/src/token-manager.ts | 361 ++++++++++++++++ node/test/package.test.ts | 11 +- node/test/token-manager.test.ts | 603 +++++++++++++++++++++++++++ 10 files changed, 1212 insertions(+), 30 deletions(-) create mode 100644 node/examples/connect-node-redis.mjs create mode 100644 node/src/token-manager.ts create mode 100644 node/test/token-manager.test.ts diff --git a/node/CHANGELOG.md b/node/CHANGELOG.md index b50d05b..bf3c1a1 100644 --- a/node/CHANGELOG.md +++ b/node/CHANGELOG.md @@ -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/`. diff --git a/node/README.md b/node/README.md index ad60e40..bcc7f82 100644 --- a/node/README.md +++ b/node/README.md @@ -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 @@ -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 @@ -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 @@ -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: @@ -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 diff --git a/node/examples/connect-iovalkey.mjs b/node/examples/connect-iovalkey.mjs index d68d540..b7d2dee 100644 --- a/node/examples/connect-iovalkey.mjs +++ b/node/examples/connect-iovalkey.mjs @@ -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() + * 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(); } 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/src/errors.ts b/node/src/errors.ts index dbbb6b6..da50e61 100644 --- a/node/src/errors.ts +++ b/node/src/errors.ts @@ -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"; + } +} diff --git a/node/src/index.ts b/node/src/index.ts index e3d7192..bc723ac 100644 --- a/node/src/index.ts +++ b/node/src/index.ts @@ -4,6 +4,7 @@ export { ConfigurationError, InvalidParameterError, + TokenRefreshError, ToolkitInputError, } from "./errors.js"; export type { @@ -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"; diff --git a/node/src/token-generator.ts b/node/src/token-generator.ts index b3d6599..1637382 100644 --- a/node/src/token-generator.ts +++ b/node/src/token-generator.ts @@ -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 @@ -346,11 +347,21 @@ export class ElastiCacheIAMAuthTokenProvider { dependencies: TokenGeneratorDependencies = {}, ): Promise { 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 { + const regionResolution = await this.regionResolution; if ("error" in regionResolution) { throw regionResolution.error; } - return provider; } get userId(): string { 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/package.test.ts b/node/test/package.test.ts index 0bf233e..130a948 100644 --- a/node/test/package.test.ts +++ b/node/test/package.test.ts @@ -101,11 +101,14 @@ test("packed package installs and exposes its public API", () => { exportsProbePath, [ 'import assert from "node:assert/strict";', - 'import { ConfigurationError, ElastiCacheIAMAuthTokenProvider, InvalidParameterError, ToolkitInputError, generateIamAuthToken } from "@aws/developer-toolkit-elasticache";', + '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], { @@ -127,7 +130,9 @@ test("package root exports the complete public API", async () => { 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( @@ -139,4 +144,8 @@ test("package root exports the complete public API", async () => { packageModule.InvalidParameterError, false, ); + assert.equal( + packageModule.TokenRefreshError.prototype instanceof packageModule.ToolkitInputError, + false, + ); }); 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; +}); From e407df04b0de6c9567554b55324cd9687ae68a97 Mon Sep 17 00:00:00 2001 From: mwaisaida-13 Date: Tue, 22 Sep 2026 14:28:47 +0100 Subject: [PATCH 20/40] Address review feedback on the token manager - Callback failures are logged by exception type only: the callback received the token, so its message or traceback might contain it. - Re-check expiry under the lock before serving the still-valid token at the end of a failed cycle; a waiter that joined because its token expired must not get it back if the clock crossed expires_at while the lock was released. - Complete and clear the signing flight before calling on_token_changed, so a timer that fires during a slow callback starts its own cycle instead of joining the old one and skipping the reschedule. Reuse decisions now compare token identity rather than issued_at. - Add refresh_token(): sign a replacement immediately for a client whose AUTH was rejected with a token the manager still considers valid; raises TokenRefreshError if none could be signed. - Tests: daemon workers with liveness asserts; shared-flight tests hold the owner open until every waiter has joined (join-counting Future); re-entrant callback test runs bounded; real _wait interrupted while blocked; real-timer end-to-end test with a callback slower than refresh_after; regression tests for the two races above. --- python/CHANGELOG.md | 16 +- python/README.md | 5 +- .../token_manager.py | 76 ++++-- python/tests/test_token_manager.py | 232 ++++++++++++++++-- 4 files changed, 285 insertions(+), 44 deletions(-) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index e12e427..55e2d17 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -8,14 +8,14 @@ All notable changes to the Python package are documented here. The format follow ### Added -- `ElastiCacheIAMAuthTokenManager` — caches a token for 880 seconds (its lifetime - minus a 900 - 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)`; `close()` or a `with` block stops - background work. +- `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`. diff --git a/python/README.md b/python/README.md index 228d440..2643197 100644 --- a/python/README.md +++ b/python/README.md @@ -122,7 +122,10 @@ with ElastiCacheIAMAuthTokenManager( `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. +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. diff --git a/python/src/developer_toolkit_elasticache/token_manager.py b/python/src/developer_toolkit_elasticache/token_manager.py index c828b36..315837e 100644 --- a/python/src/developer_toolkit_elasticache/token_manager.py +++ b/python/src/developer_toolkit_elasticache/token_manager.py @@ -47,6 +47,9 @@ "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 @@ -103,7 +106,10 @@ class ElastiCacheIAMAuthTokenManager: 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 ignored. + 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 @@ -164,7 +170,25 @@ def get_token(self) -> str: if now >= self._refresh_due_at: self._start_background_refresh_locked(cached) return cached.token - return self._refresh(reuse_if_issued_after=-math.inf) + 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.""" @@ -186,17 +210,19 @@ def _ensure_open(self) -> None: if self._closed: raise TokenRefreshError(_CLOSED_MESSAGE) - def _refresh(self, *, reuse_if_issued_after: float) -> str: - """Sign a new token, or reuse one issued after ``reuse_if_issued_after``. + def _refresh(self, *, reuse_unless: "_CachedToken | None") -> str: + """Sign a new token, unless a valid one other than ``reuse_unless`` is cached. - Joins the signing cycle already in progress, if any, and shares its outcome. + ``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.issued_at > reuse_if_issued_after + and cached is not reuse_unless and _now() < cached.expires_at ): return cached.token @@ -207,20 +233,28 @@ def _refresh(self, *, reuse_if_issued_after: float) -> str: if not owner: return flight.result() try: - token = self._run_refresh_cycle() + token, installed = self._run_refresh_cycle() except BaseException as error: flight.set_exception(error) raise else: flight.set_result(token) - return 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) -> str: - """Sign with retries. + 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 @@ -241,8 +275,7 @@ def _run_refresh_cycle(self) -> str: with self._lock: self._ensure_open() self._install_locked(token, issued_at) - self._notify(token) - return token + return token, True remaining = 0.0 with self._lock: @@ -262,7 +295,12 @@ def _run_refresh_cycle(self) -> str: remaining, _MAX_DELAY_SECONDS, ) - return cached.token + # 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 @@ -272,8 +310,9 @@ def _notify(self, token: str) -> None: return try: self._on_token_changed(token) - except Exception: # a callback failure must not discard the token - _logger.warning("on_token_changed callback raised", exc_info=True) + 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: @@ -286,12 +325,15 @@ def _backoff_delay(failed_attempt: int) -> float: 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._schedule_locked(self._refresh_after, self._cached) + 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().""" @@ -322,7 +364,7 @@ def _background_refresh(self, due: _CachedToken) -> None: # 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_if_issued_after=due.issued_at) + 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 " diff --git a/python/tests/test_token_manager.py b/python/tests/test_token_manager.py index 4295794..4911256 100644 --- a/python/tests/test_token_manager.py +++ b/python/tests/test_token_manager.py @@ -3,6 +3,7 @@ import threading import time +from concurrent.futures import Future import pytest from helpers import CACHE, REGION, ROTATED_CREDENTIALS, USER, fake_session @@ -140,8 +141,13 @@ def _manager(session, **kwargs): return ElastiCacheIAMAuthTokenManager(session=session, **kwargs) -def _run_concurrently(count, target): - """Start ``count`` threads that all enter ``target`` at once; return their results.""" +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) @@ -155,15 +161,70 @@ def worker(): with results_lock: results.append(outcome) - threads = [threading.Thread(target=worker) for _ in range(count)] + 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 ------------------------------------------------------------ @@ -253,33 +314,47 @@ def test_validates_parameters_at_construction(clock, mock_session): # --- single flight ----------------------------------------------------------------- -def test_concurrent_callers_share_one_signing(clock): +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 slow_get_credentials(): - # Hold the flight open long enough for every worker to join it. - time.sleep(0.05) + 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 = slow_get_credentials + session.get_credentials.side_effect = blocked_get_credentials manager = _manager(session) - tokens = _run_concurrently(5, manager.get_token) + 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): +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) - sleep.on_sleep = lambda: time.sleep(0.005) + 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) - outcomes = _run_concurrently(4, manager.get_token) + 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 @@ -580,6 +655,27 @@ def test_raises_token_refresh_error_once_the_cached_token_expired( 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 ): @@ -594,9 +690,80 @@ def test_expired_token_with_a_configuration_error_is_a_refresh_error( 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 = [] @@ -638,7 +805,7 @@ def test_on_token_changed_runs_outside_the_lock(clock, mock_session): ), ) - token = manager.get_token() + token = _run_bounded(manager.get_token) # a deadlock here must fail, not hang assert seen == [((USER, token), False)] @@ -656,7 +823,9 @@ def explode(_token): assert [r.levelname for r in caplog.records] == ["WARNING"] assert "on_token_changed" in caplog.text - assert "consumer bug" 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 @@ -770,11 +939,38 @@ def close_mid_signing(): assert manager._cached is None -def test_real_wait_returns_early_when_stopped(): +def test_real_wait_is_interrupted_by_close(): stop = threading.Event() - stop.set() + 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_WAIT(stop, 60) - assert time.monotonic() - started < 1 +# --- 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" From 039c0410eecf2601ad79ce61257e8253e6c43109 Mon Sep 17 00:00:00 2001 From: mwaisaida-13 Date: Tue, 22 Sep 2026 14:58:24 +0100 Subject: [PATCH 21/40] Add the npm release workflow for the Node package Mirrors python-release.yml: on a published node-v* GitHub Release, run the supported-version test matrix, verify and pack, then publish from the `npm` environment with OIDC trusted publishing and provenance, and smoke-test the published package. Release logic lives in scripts, not YAML: - scripts/verify.mjs: tag matches package.json's version, `private` is off, CHANGELOG.md has an entry for the version, repository.url is set (provenance requires it), and optionally npm is new enough for trusted publishing. The publish job asserts the npm version instead of installing npm from the registry at publish time. - scripts/smoke.mjs: install the published version into a temporary project with retries, import it, check the public exports and the installed version. package.json gains repository/homepage/bugs (provenance verifies repository.url against the publishing repository) and publishConfig.access=public, which a scoped package needs on first publish. Nothing publishes until a node-v* release is created, the `npm` GitHub environment exists with required reviewers, and the npm trusted publisher is configured for the package. --- .github/workflows/node-release.yml | 145 +++++++++++++++++++++++++++++ node/package.json | 12 +++ node/scripts/smoke.mjs | 95 +++++++++++++++++++ node/scripts/verify.mjs | 93 ++++++++++++++++++ node/test/verify.test.ts | 136 +++++++++++++++++++++++++++ 5 files changed, 481 insertions(+) create mode 100644 .github/workflows/node-release.yml create mode 100644 node/scripts/smoke.mjs create mode 100644 node/scripts/verify.mjs create mode 100644 node/test/verify.test.ts diff --git a/.github/workflows/node-release.yml b/.github/workflows/node-release.yml new file mode 100644 index 0000000..0b0c26f --- /dev/null +++ b/.github/workflows/node-release.yml @@ -0,0 +1,145 @@ +name: Publish Node package to npm + +# Follows https://docs.npmjs.com/trusted-publishers. The npm trusted publisher must +# reference this exact filename and the `npm` environment, or OIDC publishing is +# rejected. Provenance is generated automatically by trusted publishing. + +on: + release: + types: [published] + +permissions: + contents: read + +# cancel-in-progress: false — cancelling mid-publish can leave a release half done. +concurrency: + group: node-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + # Source of truth for the test matrix: package.json's supportedNodeVersions. + # node-v* tags only — test/build/publish inherit this via `needs`. + versions: + name: node versions + if: startsWith(github.ref_name, 'node-v') + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + data: ${{ steps.set.outputs.data }} + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Generate supported versions from package.json + id: set + working-directory: node + run: echo "data=$(npm run --silent list-supported-versions)" >> "$GITHUB_OUTPUT" + + # Full support matrix gates the release. Ubuntu only: the package is pure JavaScript + # and the CI workflow already covers Windows and macOS. No dependency caching in + # release builds, per npm's trusted-publishing guidance. + test: + name: test (Node ${{ matrix.node-version }}) + needs: versions + runs-on: ubuntu-latest + timeout-minutes: 15 + strategy: + fail-fast: false # report every version, not just the first failure + matrix: + node-version: ${{ fromJson(needs.versions.outputs.data).all }} + defaults: + run: + working-directory: node + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js ${{ matrix.node-version }} + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: ${{ matrix.node-version }} + package-manager-cache: false + + - name: Install dependencies + run: npm ci + + - name: Run the test suite + timeout-minutes: 10 + run: npm test + + build: + name: build and verify + needs: [versions, test] + if: startsWith(github.ref_name, 'node-v') + runs-on: ubuntu-latest + timeout-minutes: 15 + defaults: + run: + working-directory: node + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: ${{ fromJson(needs.versions.outputs.data).max }} + package-manager-cache: false + + - name: Install dependencies + run: npm ci + + # Tag must match package.json's version, `private` must be gone, CHANGELOG must + # have an entry, and repository.url must be set (provenance verifies it). + - name: Verify the tag matches the packaged version and the package is publishable + run: npm run --silent verify -- "$GITHUB_REF_NAME" + + - name: Build and validate the package contents + timeout-minutes: 5 + run: | + npm run build + npm pack --dry-run + + publish: + name: publish + needs: [versions, build] + if: startsWith(github.ref_name, 'node-v') + runs-on: ubuntu-latest + timeout-minutes: 15 + # Must match the environment name on the npm trusted publisher; carries the + # required-reviewer gate so a run alone cannot publish. + environment: npm + permissions: + # Job permissions REPLACE the top-level block, so restate contents: read. + contents: read + id-token: write # OIDC token for npm trusted publishing + defaults: + run: + working-directory: node + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Node.js + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 + with: + node-version: ${{ fromJson(needs.versions.outputs.data).max }} + registry-url: https://registry.npmjs.org + package-manager-cache: false + + # Trusted publishing needs npm >= 11.5.1; the newest supported Node bundles a + # newer one, so assert rather than install npm from the registry at publish time. + - name: Verify again on the publishing runner, including the npm version + run: npm run --silent verify -- "$GITHUB_REF_NAME" --require-npm 11.5.1 + + - name: Install dependencies and build + run: | + npm ci + npm run build + + # OIDC: no token, no --provenance flag; publishConfig.access is public. + - name: Publish to npm + run: npm publish + + - name: Smoke-test the published package + run: npm run --silent smoke -- "${GITHUB_REF_NAME#node-v}" diff --git a/node/package.json b/node/package.json index 0fa62ba..60a3d70 100644 --- a/node/package.json +++ b/node/package.json @@ -2,6 +2,13 @@ "name": "@aws/developer-toolkit-elasticache", "version": "0.1.0-dev.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", "private": true, "license": "Apache-2.0", @@ -19,6 +26,9 @@ "NOTICE", "README.md" ], + "publishConfig": { + "access": "public" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -35,6 +45,8 @@ "format:check": "prettier --check .", "lint": "eslint .", "list-supported-versions": "node scripts/list-supported-versions.mjs", + "verify": "node scripts/verify.mjs", + "smoke": "node scripts/smoke.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" diff --git a/node/scripts/smoke.mjs b/node/scripts/smoke.mjs new file mode 100644 index 0000000..5e3340f --- /dev/null +++ b/node/scripts/smoke.mjs @@ -0,0 +1,95 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Post-publish smoke test, run by node-release.yml after npm publish: +// +// node scripts/smoke.mjs [VERSION] +// +// Installs the just-published version from the npm registry into a temporary project +// (retrying while the registry catches up), imports it, and checks that the public API +// is exported and that the installed version is the one that was released. + +import { spawnSync } from "node:child_process"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { argv, env, execPath, exit, platform, stdout } from "node:process"; +import { setTimeout as sleep } from "node:timers/promises"; + +const PACKAGE_NAME = "@aws/developer-toolkit-elasticache"; +const TAG_PREFIX = "node-v"; +const ATTEMPTS = 5; +const RETRY_DELAY_MS = 15_000; +const EXPECTED_EXPORTS = [ + "ConfigurationError", + "ElastiCacheIAMAuthTokenManager", + "ElastiCacheIAMAuthTokenProvider", + "InvalidParameterError", + "TokenRefreshError", + "ToolkitInputError", + "generateIamAuthToken", +]; + +function fail(message) { + stdout.write(`::error::${message}\n`); + exit(1); +} + +const version = argv[2] || (env.GITHUB_REF_NAME ?? "").replace(TAG_PREFIX, ""); +if (!version) { + fail("No version given and none could be derived from GITHUB_REF_NAME."); +} + +const npm = platform === "win32" ? "npm.cmd" : "npm"; +const project = mkdtempSync(join(tmpdir(), "elasticache-smoke-")); +try { + writeFileSync(join(project, "package.json"), JSON.stringify({ type: "module" })); + const spec = `${PACKAGE_NAME}@${version}`; + + let installed = false; + for (let attempt = 1; attempt <= ATTEMPTS && !installed; attempt++) { + const result = spawnSync( + npm, + ["install", spec, "--no-audit", "--no-fund", "--ignore-scripts"], + { cwd: project, encoding: "utf8", shell: platform === "win32" }, + ); + installed = result.status === 0; + if (!installed && attempt < ATTEMPTS) { + stdout.write( + `install attempt ${attempt} failed (registry may lag the upload); ` + + `retrying in ${RETRY_DELAY_MS / 1000}s\n`, + ); + await sleep(RETRY_DELAY_MS); + } + } + if (!installed) { + fail(`Could not install ${spec} from the npm registry after ${ATTEMPTS} attempts.`); + } + + // The probe runs inside the temporary project so it resolves the installed copy. + const probe = join(project, "probe.mjs"); + const manifestSpecifier = JSON.stringify(`${PACKAGE_NAME}/package.json`); + writeFileSync( + probe, + [ + `import * as pkg from ${JSON.stringify(PACKAGE_NAME)};`, + `import { createRequire } from "node:module";`, + `const manifest = createRequire(import.meta.url)(${manifestSpecifier});`, + `const missing = ${JSON.stringify(EXPECTED_EXPORTS)}.filter((name) => !(name in pkg));`, + `if (missing.length) throw new Error("missing exports: " + missing.join(", "));`, + `if (manifest.version !== ${JSON.stringify(version)})`, + ` throw new Error("installed " + manifest.version + ", expected " + ${JSON.stringify(version)});`, + ].join("\n"), + ); + const check = spawnSync(execPath, [probe], { cwd: project, encoding: "utf8" }); + if (check.status !== 0) { + const reason = + check.stderr.split("\n").find((line) => line.startsWith("Error:")) ?? + check.stderr.trim(); + fail(`Published package failed the import check: ${reason}`); + } +} finally { + rmSync(project, { recursive: true, force: true }); +} + +stdout.write(`Smoke test passed for ${PACKAGE_NAME}@${version}.\n`); diff --git a/node/scripts/verify.mjs b/node/scripts/verify.mjs new file mode 100644 index 0000000..1a1c3fb --- /dev/null +++ b/node/scripts/verify.mjs @@ -0,0 +1,93 @@ +// 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 CHANGELOG.md has an entry for the +// version, and that package.json declares the repository (npm provenance requires it). +// 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); +} + +/** True when dotted version `a` is older than `b`. */ +function olderThan(a, b) { + const [left, right] = [a, b].map((value) => value.split(".").map(Number)); + 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]; +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.", + ); +} + +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) { + const npm = platform === "win32" ? "npm.cmd" : "npm"; + const installed = execFileSync(npm, ["--version"], { encoding: "utf8" }).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/test/verify.test.ts b/node/test/verify.test.ts new file mode 100644 index 0000000..a3c3c28 --- /dev/null +++ b/node/test/verify.test.ts @@ -0,0 +1,136 @@ +// 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, +) { + 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, GITHUB_REF_NAME: "" }, + }); + } 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 temporaryRoot = mkdtempSync(join(tmpdir(), "elasticache-node-verify-")); + try { + mkdirSync(join(temporaryRoot, "scripts")); + copyFileSync(SCRIPT_PATH, join(temporaryRoot, "scripts", "verify.mjs")); + writeFileSync(join(temporaryRoot, "package.json"), JSON.stringify(RELEASABLE)); + writeFileSync(join(temporaryRoot, "CHANGELOG.md"), CHANGELOG_WITH_ENTRY); + const result = spawnSync( + process.execPath, + [join(temporaryRoot, "scripts", "verify.mjs")], + { + encoding: "utf8", + env: { ...process.env, GITHUB_REF_NAME: "node-v1.2.3" }, + }, + ); + assert.equal(result.status, 0, result.stdout + result.stderr); + } finally { + rmSync(temporaryRoot, { recursive: true, force: true }); + } +}); + +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 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); +}); From c2d041c0eade0ba13f1a55a14aee7f5113242353 Mon Sep 17 00:00:00 2001 From: "Alexandre [C] Luc Rehnby-Martin" Date: Tue, 22 Sep 2026 07:49:45 -0700 Subject: [PATCH 22/40] Run token callbacks on refresh thread --- java/README.md | 5 ++- .../auth/ElastiCacheIamAuthTokenManager.java | 32 ++++++------- .../ElastiCacheIamAuthTokenManagerTest.java | 45 ++++++++++--------- 3 files changed, 42 insertions(+), 40 deletions(-) diff --git a/java/README.md b/java/README.md index 044fa66..0f3793f 100644 --- a/java/README.md +++ b/java/README.md @@ -109,8 +109,9 @@ generation, and subsequent calls return the cached token while it is valid. Refresh starts after 5 minutes by default; `refreshAfter(...)` accepts values from 1 millisecond up to, but not including, 14 minutes 40 seconds. The manager reserves the final 20 seconds of the service's 15-minute token lifetime for clock skew and -in-transit reconnects. Token-change callbacks run asynchronously after the new -token is available to callers. +in-transit reconnects. Token-change callbacks run on the manager's refresh thread +after the new token is available to callers and should return quickly so they do +not delay that manager's next refresh. A refresh makes up to 8 attempts with jittered exponential backoff capped at 5 seconds. If refresh fails while the cached token is still valid, callers continue diff --git a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java index f645987..bb289ba 100644 --- a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java +++ b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java @@ -9,9 +9,7 @@ import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; -import java.util.concurrent.Executor; import java.util.concurrent.Executors; -import java.util.concurrent.ForkJoinPool; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; @@ -57,7 +55,6 @@ public final class ElastiCacheIamAuthTokenManager implements AutoCloseable { private final ElastiCacheIamAuthTokenProvider provider; private final long refreshAfterMillis; private final Consumer onTokenChanged; - private final Executor callbackExecutor; private final LongSupplier currentTimeMillis; private final DoubleSupplier random; private final Scheduler scheduler; @@ -93,9 +90,6 @@ private ElastiCacheIamAuthTokenManager(Builder builder) { .build(); this.refreshAfterMillis = resolveRefreshAfterMillis(builder.refreshAfter); this.onTokenChanged = builder.onTokenChanged; - this.callbackExecutor = builder.callbackExecutor == null - ? ForkJoinPool.commonPool() - : builder.callbackExecutor; this.currentTimeMillis = builder.currentTimeMillis == null ? System::currentTimeMillis : builder.currentTimeMillis; @@ -329,8 +323,8 @@ private void installAndComplete( retryNotBefore = 0; scheduleRefreshLocked(refreshAfterMillis); refresh.complete(token); - notifyTokenChanged(token); } + notifyTokenChanged(token); } private void notifyTokenChanged(String token) { @@ -338,7 +332,7 @@ private void notifyTokenChanged(String token) { return; } try { - callbackExecutor.execute(() -> { + scheduler.execute(() -> { try { onTokenChanged.accept(token); } catch (Throwable ignored) { @@ -463,7 +457,6 @@ public static final class Builder { private AwsCredentialsProvider credentialsProvider; private Duration refreshAfter; private Consumer onTokenChanged; - private Executor callbackExecutor; private Supplier awsRegionEnvironmentProvider; private AwsRegionProvider regionProvider; private Clock signingClock; @@ -547,10 +540,12 @@ public Builder refreshAfter(Duration refreshAfter) { } /** - * Sets a callback invoked asynchronously after each newly generated token - * is installed and made available to token callers. + * Sets a callback invoked on the manager's refresh thread after each newly + * generated token is installed and made available to token callers. * - *

Callback failures are ignored and do not discard the new token. + *

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 @@ -591,12 +586,6 @@ Builder scheduler(Scheduler scheduler) { return this; } - Builder callbackExecutor(Executor callbackExecutor) { - this.callbackExecutor = - Objects.requireNonNull(callbackExecutor, "callbackExecutor"); - return this; - } - Builder beforeInstall(Runnable beforeInstall) { this.beforeInstall = Objects.requireNonNull(beforeInstall, "beforeInstall"); return this; @@ -625,6 +614,8 @@ interface Cancellable { interface Scheduler { Cancellable schedule(Runnable task, long delayMillis); + void execute(Runnable task); + void close(); } @@ -639,6 +630,11 @@ public Cancellable schedule(Runnable task, long delayMillis) { return () -> future.cancel(false); } + @Override + public void execute(Runnable task) { + executor.execute(task); + } + @Override public void close() { executor.shutdownNow(); diff --git a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java index 21cabf1..1e0022f 100644 --- a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java +++ b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java @@ -20,11 +20,10 @@ 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; @@ -368,32 +367,35 @@ void ignoresTokenChangedCallbackFailures() { @Test void blockingTokenChangedCallbackDoesNotDelayTokenCaller() throws Exception { - TestScheduler scheduler = new TestScheduler(); CountDownLatch callbackEntered = new CountDownLatch(1); CountDownLatch releaseCallback = new CountDownLatch(1); - ExecutorService callbackExecutor = Executors.newSingleThreadExecutor(); - ElastiCacheIamAuthTokenManager manager = - baseBuilder(scheduler, new AtomicInteger(), new AtomicBoolean()) - .callbackExecutor(callbackExecutor) - .onTokenChanged(token -> { - callbackEntered.countDown(); - try { - releaseCallback.await(); - } catch (InterruptedException exception) { - Thread.currentThread().interrupt(); - } - }) - .build(); + 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(); - callbackExecutor.shutdownNow(); } } @@ -455,7 +457,6 @@ void closeAfterSigningPreventsTokenInstallationAndNotification() { CountDownLatch install = new CountDownLatch(1); ElastiCacheIamAuthTokenManager manager = baseBuilder(scheduler, new AtomicInteger(), new AtomicBoolean()) - .callbackExecutor(Runnable::run) .onTokenChanged(changedTokens::add) .beforeInstall(() -> { signed.countDown(); @@ -525,8 +526,7 @@ private static ElastiCacheIamAuthTokenManager.Builder baseBuilder( .signingClock(SIGNING_CLOCK) .currentTimeMillis(scheduler::now) .random(() -> 0.5) - .scheduler(scheduler) - .callbackExecutor(Runnable::run); + .scheduler(scheduler); } private static void awaitCreatedTasks(TestScheduler scheduler, int expected) { @@ -602,6 +602,11 @@ public synchronized ElastiCacheIamAuthTokenManager.Cancellable schedule( return scheduled; } + @Override + public void execute(Runnable task) { + task.run(); + } + @Override public void close() {} From 82089a746b8ab643a62d6a2b7059362b855fb56c Mon Sep 17 00:00:00 2001 From: mwaisaida-13 Date: Tue, 22 Sep 2026 16:14:16 +0100 Subject: [PATCH 23/40] fix(node): spawn npm through a shell on Windows in verify.mjs Node refuses to spawn .cmd shims directly (spawnSync npm.cmd EINVAL), which failed the --require-npm check on the Windows CI runners. --- node/scripts/verify.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/node/scripts/verify.mjs b/node/scripts/verify.mjs index 1a1c3fb..141c100 100644 --- a/node/scripts/verify.mjs +++ b/node/scripts/verify.mjs @@ -80,8 +80,12 @@ if (!changelog.includes(`## [${version}]`)) { } 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" }).trim(); + 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 ` + From 09585a13d24fa70b020c0974877722d13bdc805e Mon Sep 17 00:00:00 2001 From: mwaisaida-13 Date: Thu, 24 Sep 2026 20:26:40 +0100 Subject: [PATCH 24/40] ci(node): move the post-publish smoke test into scripts/node_dev.py Same shape as scripts/java_dev.py: a small stdlib-only Python script for the step npm has no command for. The release gate stays in node/scripts/verify.mjs (npm run verify, with its tests); everything else is plain npm in the workflow. - smoke VERSION installs the exact published version into a temporary project and checks the public exports. It retries 20 times, 40s apart (about 13 minutes), with a fresh npm cache and --prefer-online each time, logging 'install attempt X/20 failed (registry may lag the upload)': the registry has taken around 8 minutes to serve a newly published tarball. - Replaces node/scripts/smoke.mjs. - The publish job gains a repository_owner guard, so a fork can never publish, and the build job packs into node/pack/ for the publish job. --- .github/workflows/node-release.yml | 69 ++++++++++++---------- node/.gitignore | 1 + node/package.json | 1 - node/scripts/smoke.mjs | 95 ------------------------------ scripts/node_dev.py | 92 +++++++++++++++++++++++++++++ 5 files changed, 130 insertions(+), 128 deletions(-) delete mode 100644 node/scripts/smoke.mjs create mode 100644 scripts/node_dev.py diff --git a/.github/workflows/node-release.yml b/.github/workflows/node-release.yml index 0b0c26f..22e9fe6 100644 --- a/.github/workflows/node-release.yml +++ b/.github/workflows/node-release.yml @@ -1,8 +1,7 @@ name: Publish Node package to npm -# Follows https://docs.npmjs.com/trusted-publishers. The npm trusted publisher must -# reference this exact filename and the `npm` environment, or OIDC publishing is -# rejected. Provenance is generated automatically by trusted publishing. +# The npm trusted publisher must reference this exact filename and the `npm` +# environment (https://docs.npmjs.com/trusted-publishers). on: release: @@ -37,7 +36,7 @@ jobs: # Full support matrix gates the release. Ubuntu only: the package is pure JavaScript # and the CI workflow already covers Windows and macOS. No dependency caching in - # release builds, per npm's trusted-publishing guidance. + # release jobs, per npm's trusted-publishing guidance. test: name: test (Node ${{ matrix.node-version }}) needs: versions @@ -60,12 +59,11 @@ jobs: node-version: ${{ matrix.node-version }} package-manager-cache: false - - name: Install dependencies - run: npm ci - - - name: Run the test suite + - name: Install and run the test suite timeout-minutes: 10 - run: npm test + run: | + npm ci + npm test build: name: build and verify @@ -73,9 +71,6 @@ jobs: if: startsWith(github.ref_name, 'node-v') runs-on: ubuntu-latest timeout-minutes: 15 - defaults: - run: - working-directory: node steps: - name: Check out the repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -86,26 +81,35 @@ jobs: node-version: ${{ fromJson(needs.versions.outputs.data).max }} package-manager-cache: false - - name: Install dependencies - run: npm ci - # Tag must match package.json's version, `private` must be gone, CHANGELOG must # have an entry, and repository.url must be set (provenance verifies it). - name: Verify the tag matches the packaged version and the package is publishable + working-directory: node run: npm run --silent verify -- "$GITHUB_REF_NAME" - - name: Build and validate the package contents + - name: Build and pack the tarball + working-directory: node timeout-minutes: 5 run: | + npm ci npm run build - npm pack --dry-run + mkdir pack + npm pack --pack-destination pack + + - name: Save the tarball for the publish job + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: node-dist + path: node/pack/ + if-no-files-found: error # an empty artifact silently breaks the publish job publish: name: publish needs: [versions, build] - if: startsWith(github.ref_name, 'node-v') + # A fork that copies this workflow and its tags can never publish. + if: startsWith(github.ref_name, 'node-v') && github.repository_owner == 'aws' runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 20 # Must match the environment name on the npm trusted publisher; carries the # required-reviewer gate so a run alone cannot publish. environment: npm @@ -113,13 +117,12 @@ jobs: # Job permissions REPLACE the top-level block, so restate contents: read. contents: read id-token: write # OIDC token for npm trusted publishing - defaults: - run: - working-directory: node steps: - name: Check out the repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + # No NODE_AUTH_TOKEN: auth comes from the OIDC token, which npm >= 11.5.1 uses + # automatically. - name: Set up Node.js uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6.0.0 with: @@ -127,19 +130,21 @@ jobs: registry-url: https://registry.npmjs.org package-manager-cache: false - # Trusted publishing needs npm >= 11.5.1; the newest supported Node bundles a - # newer one, so assert rather than install npm from the registry at publish time. + # The newest supported Node bundles npm >= 11.5.1; assert it rather than install + # npm from the registry at publish time. - name: Verify again on the publishing runner, including the npm version + working-directory: node run: npm run --silent verify -- "$GITHUB_REF_NAME" --require-npm 11.5.1 - - name: Install dependencies and build - run: | - npm ci - npm run build + - name: Retrieve the tarball from the build job + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: node-dist + path: node/pack/ - # OIDC: no token, no --provenance flag; publishConfig.access is public. - - name: Publish to npm - run: npm publish + - name: Publish to npm with provenance + run: npm publish node/pack/*.tgz --provenance --access public + # Retries for ~13 minutes (20 x 40s): the registry lags behind a publish. - name: Smoke-test the published package - run: npm run --silent smoke -- "${GITHUB_REF_NAME#node-v}" + run: python3 scripts/node_dev.py smoke "${GITHUB_REF_NAME#node-v}" diff --git a/node/.gitignore b/node/.gitignore index 4abc033..cc1b485 100644 --- a/node/.gitignore +++ b/node/.gitignore @@ -5,6 +5,7 @@ node_modules/ # npm *.tgz +pack/ npm-debug.log* # Test / coverage output diff --git a/node/package.json b/node/package.json index 60a3d70..0b78ec6 100644 --- a/node/package.json +++ b/node/package.json @@ -46,7 +46,6 @@ "lint": "eslint .", "list-supported-versions": "node scripts/list-supported-versions.mjs", "verify": "node scripts/verify.mjs", - "smoke": "node scripts/smoke.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" diff --git a/node/scripts/smoke.mjs b/node/scripts/smoke.mjs deleted file mode 100644 index 5e3340f..0000000 --- a/node/scripts/smoke.mjs +++ /dev/null @@ -1,95 +0,0 @@ -// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. -// SPDX-License-Identifier: Apache-2.0 - -// Post-publish smoke test, run by node-release.yml after npm publish: -// -// node scripts/smoke.mjs [VERSION] -// -// Installs the just-published version from the npm registry into a temporary project -// (retrying while the registry catches up), imports it, and checks that the public API -// is exported and that the installed version is the one that was released. - -import { spawnSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { argv, env, execPath, exit, platform, stdout } from "node:process"; -import { setTimeout as sleep } from "node:timers/promises"; - -const PACKAGE_NAME = "@aws/developer-toolkit-elasticache"; -const TAG_PREFIX = "node-v"; -const ATTEMPTS = 5; -const RETRY_DELAY_MS = 15_000; -const EXPECTED_EXPORTS = [ - "ConfigurationError", - "ElastiCacheIAMAuthTokenManager", - "ElastiCacheIAMAuthTokenProvider", - "InvalidParameterError", - "TokenRefreshError", - "ToolkitInputError", - "generateIamAuthToken", -]; - -function fail(message) { - stdout.write(`::error::${message}\n`); - exit(1); -} - -const version = argv[2] || (env.GITHUB_REF_NAME ?? "").replace(TAG_PREFIX, ""); -if (!version) { - fail("No version given and none could be derived from GITHUB_REF_NAME."); -} - -const npm = platform === "win32" ? "npm.cmd" : "npm"; -const project = mkdtempSync(join(tmpdir(), "elasticache-smoke-")); -try { - writeFileSync(join(project, "package.json"), JSON.stringify({ type: "module" })); - const spec = `${PACKAGE_NAME}@${version}`; - - let installed = false; - for (let attempt = 1; attempt <= ATTEMPTS && !installed; attempt++) { - const result = spawnSync( - npm, - ["install", spec, "--no-audit", "--no-fund", "--ignore-scripts"], - { cwd: project, encoding: "utf8", shell: platform === "win32" }, - ); - installed = result.status === 0; - if (!installed && attempt < ATTEMPTS) { - stdout.write( - `install attempt ${attempt} failed (registry may lag the upload); ` + - `retrying in ${RETRY_DELAY_MS / 1000}s\n`, - ); - await sleep(RETRY_DELAY_MS); - } - } - if (!installed) { - fail(`Could not install ${spec} from the npm registry after ${ATTEMPTS} attempts.`); - } - - // The probe runs inside the temporary project so it resolves the installed copy. - const probe = join(project, "probe.mjs"); - const manifestSpecifier = JSON.stringify(`${PACKAGE_NAME}/package.json`); - writeFileSync( - probe, - [ - `import * as pkg from ${JSON.stringify(PACKAGE_NAME)};`, - `import { createRequire } from "node:module";`, - `const manifest = createRequire(import.meta.url)(${manifestSpecifier});`, - `const missing = ${JSON.stringify(EXPECTED_EXPORTS)}.filter((name) => !(name in pkg));`, - `if (missing.length) throw new Error("missing exports: " + missing.join(", "));`, - `if (manifest.version !== ${JSON.stringify(version)})`, - ` throw new Error("installed " + manifest.version + ", expected " + ${JSON.stringify(version)});`, - ].join("\n"), - ); - const check = spawnSync(execPath, [probe], { cwd: project, encoding: "utf8" }); - if (check.status !== 0) { - const reason = - check.stderr.split("\n").find((line) => line.startsWith("Error:")) ?? - check.stderr.trim(); - fail(`Published package failed the import check: ${reason}`); - } -} finally { - rmSync(project, { recursive: true, force: true }); -} - -stdout.write(`Smoke test passed for ${PACKAGE_NAME}@${version}.\n`); 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() From a005341c5c9c5cf2419a1af0684b9971fa40a138 Mon Sep 17 00:00:00 2001 From: mwaisaida-13 Date: Thu, 24 Sep 2026 20:43:43 +0100 Subject: [PATCH 25/40] ci(python): run Python CI only when its own runner changes python.yml watched scripts/**, so a change to another language's runner (scripts/node_dev.py) also ran the Python matrix. Watch scripts/python_dev.py, like java.yml watches scripts/java_dev.py. --- .github/workflows/python.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml index 7f94871..cc52543 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -7,12 +7,12 @@ on: branches: [main] paths: - "python/**" - - "scripts/**" + - "scripts/python_dev.py" - ".github/workflows/python.yml" pull_request: paths: - "python/**" - - "scripts/**" + - "scripts/python_dev.py" - ".github/workflows/python.yml" jobs: From 7ca4fc9d46a8b1c0ce7893e9bb44babffd977230 Mon Sep 17 00:00:00 2001 From: "Alexandre [C] Luc Rehnby-Martin" Date: Thu, 24 Sep 2026 13:02:43 -0700 Subject: [PATCH 26/40] Add forced IAM token refresh API --- java/CHANGELOG.md | 2 + java/README.md | 32 +++ .../integration/PublicApiTest.java | 6 +- .../auth/ElastiCacheIamAuthTokenManager.java | 26 +++ .../ElastiCacheIamAuthTokenManagerTest.java | 199 ++++++++++++++++++ 5 files changed, 264 insertions(+), 1 deletion(-) diff --git a/java/CHANGELOG.md b/java/CHANGELOG.md index f92b138..fee183c 100644 --- a/java/CHANGELOG.md +++ b/java/CHANGELOG.md @@ -18,6 +18,8 @@ and this package uses [Semantic Versioning](https://semver.org/). - `ElastiCacheIamAuthTokenManager` for caching tokens, refreshing them in the background before expiry, retrying transient failures, and notifying a token-change callback. +- `ElastiCacheIamAuthTokenManager.refreshToken()` for replacing a rejected cached + token immediately while sharing concurrent and background refresh work. - `TokenRefreshException` for closed managers and refreshes that cannot replace an expired token. - Maven build, Java version CI matrix, documentation, and dependency updates. diff --git a/java/README.md b/java/README.md index 0f3793f..90dd39f 100644 --- a/java/README.md +++ b/java/README.md @@ -119,6 +119,14 @@ to receive that token and another refresh is scheduled. If the token expires before it can be replaced, `getToken()` throws `TokenRefreshException` with the last refresh failure as its cause. +Call `refreshToken()` after a client rejects the cached token, instead of waiting +for the scheduled refresh. It invalidates that token immediately, resolves AWS +credentials again, signs and installs a replacement, and returns it. Concurrent +`refreshToken()`, `getToken()`, and `getCredentials()` calls share the same +replacement operation, including a background refresh already in progress. If the +replacement cannot be signed, the rejected token remains invalidated and the +failure is reported to the caller. + The refresh thread is a daemon and does not keep the JVM alive. Close the manager to cancel background work; token requests after close throw `TokenRefreshException`. @@ -147,6 +155,30 @@ RedisCredentialsProvider credentialsProvider = new RedisCredentialsProvider() { }; ``` +If the client reports an AUTH rejection, discard that connection before forcing a +refresh and beginning the next bounded connection attempt: + +```java +try { + return openConnection(credentialsProvider); +} catch (RedisAuthenticationException authFailure) { + // Never return the rejected connection to the pool. + closeRejectedConnection(); + + // Resolve AWS credentials again and install a replacement token. The next + // openConnection call obtains it through credentialsProvider. + auth.refreshToken(); + throw authFailure; // Let the existing bounded reconnect policy retry. +} +``` + +`RedisAuthenticationException`, `openConnection`, and +`closeRejectedConnection` represent the corresponding hooks in your client or +connection pool. Forced refresh is recovery from a rejected cached token, not +proof that the IAM user, policy, cache, or region is configured correctly. +Persistent configuration errors should still surface after the client's normal +connection-attempt limit. + Run the complete [`ConnectWithLettuce`](examples/src/main/java/software/amazon/elasticache/examples/ConnectWithLettuce.java) example after installing the toolkit: 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 index d2e7bcb..8b6f7f5 100644 --- 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 @@ -8,6 +8,8 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.lang.reflect.Method; + import org.junit.jupiter.api.Test; import software.amazon.elasticache.auth.ConfigurationException; import software.amazon.elasticache.auth.ElastiCacheIamAuthTokenManager; @@ -18,9 +20,11 @@ class PublicApiTest { @Test - void installedArtifactExposesPublicApi() { + void installedArtifactExposesPublicApi() throws Exception { assertNotNull(ElastiCacheIamAuthTokenProvider.builder()); assertNotNull(ElastiCacheIamAuthTokenManager.builder()); + Method refreshToken = ElastiCacheIamAuthTokenManager.class.getMethod("refreshToken"); + assertTrue(refreshToken.getReturnType().equals(String.class)); assertTrue(ToolkitInputException.class.isAssignableFrom(ConfigurationException.class)); assertTrue(ToolkitInputException.class.isAssignableFrom(InvalidParameterException.class)); assertFalse(ToolkitInputException.class.isAssignableFrom(TokenRefreshException.class)); diff --git a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java index bb289ba..07ec66b 100644 --- a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java +++ b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java @@ -157,6 +157,32 @@ public String getToken() { return await(refresh()); } + /** + * Forces an immediate token 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. + * + *

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. * diff --git a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java index 1e0022f..1a5067c 100644 --- a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java +++ b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java @@ -116,6 +116,162 @@ void returnsNormalizedCredentialsAndCachedToken() { } } + @Test + void forcedRefreshImmediatelyUsesRotatedCredentialsAndResetsScheduling() { + 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(java.util.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); + 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); + await(replacementStarted); + CompletableFuture second = + CompletableFuture.supplyAsync(manager::refreshToken); + CompletableFuture token = + CompletableFuture.supplyAsync(manager::getToken); + CompletableFuture credentials = + CompletableFuture.supplyAsync(manager::getCredentials); + 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(); + } + } + + @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); + assertNotEquals(rejected, forcedFailure.getMessage()); + 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(); @@ -408,6 +564,49 @@ void closeCancelsBackgroundWorkAndRejectsRequests() { 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 From fb58fe9d1418cf392b49cd3dda1f712ce746da2f Mon Sep 17 00:00:00 2001 From: "Alexandre [C] Luc Rehnby-Martin" Date: Thu, 24 Sep 2026 14:52:57 -0700 Subject: [PATCH 27/40] Fix forced refresh concurrency test on CI --- .../auth/ElastiCacheIamAuthTokenManagerTest.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java index 1a5067c..9810ced 100644 --- a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java +++ b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java @@ -20,6 +20,8 @@ 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; @@ -147,6 +149,7 @@ void concurrentForcedRefreshesAndTokenCallersShareReplacement() { 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) @@ -167,14 +170,14 @@ void concurrentForcedRefreshesAndTokenCallersShareReplacement() { try { String rejected = manager.getToken(); CompletableFuture first = - CompletableFuture.supplyAsync(manager::refreshToken); + CompletableFuture.supplyAsync(manager::refreshToken, callers); await(replacementStarted); CompletableFuture second = - CompletableFuture.supplyAsync(manager::refreshToken); + CompletableFuture.supplyAsync(manager::refreshToken, callers); CompletableFuture token = - CompletableFuture.supplyAsync(manager::getToken); + CompletableFuture.supplyAsync(manager::getToken, callers); CompletableFuture credentials = - CompletableFuture.supplyAsync(manager::getCredentials); + CompletableFuture.supplyAsync(manager::getCredentials, callers); await(shared); releaseReplacement.countDown(); @@ -187,6 +190,7 @@ void concurrentForcedRefreshesAndTokenCallersShareReplacement() { } finally { releaseReplacement.countDown(); manager.close(); + callers.shutdownNow(); } } From 3fd40a32a504c78a3f19522d5c5186f0ddb3e8b9 Mon Sep 17 00:00:00 2001 From: mwaisaida-13 Date: Thu, 24 Sep 2026 23:39:10 +0100 Subject: [PATCH 28/40] ci(node): tighten the release gate and support release candidates verify.mjs: - Accept X.Y.Z and X.Y.Z-rc.N only. Release candidates publish under the `next` dist-tag (verify writes dist_tag to GITHUB_OUTPUT and the publish step passes it), so `latest` keeps pointing at the last full release. Any other prerelease suffix is rejected in the gate; npm itself refuses a prerelease without an explicit --tag, which would only fail at the upload. - Reject --require-npm without a value; it silently skipped the npm check. - Reject non-numeric version parts instead of comparing NaN, which passed silently. Tests: runVerify takes an env parameter; cases for each of the above. --- .github/workflows/node-release.yml | 4 +- node/scripts/verify.mjs | 32 ++++++++- node/test/verify.test.ts | 102 +++++++++++++++++++++++------ 3 files changed, 113 insertions(+), 25 deletions(-) diff --git a/.github/workflows/node-release.yml b/.github/workflows/node-release.yml index 22e9fe6..dcf6106 100644 --- a/.github/workflows/node-release.yml +++ b/.github/workflows/node-release.yml @@ -132,7 +132,9 @@ jobs: # The newest supported Node bundles npm >= 11.5.1; assert it rather than install # npm from the registry at publish time. + # Also decides the dist-tag: `latest` for releases, `next` for X.Y.Z-rc.N. - name: Verify again on the publishing runner, including the npm version + id: verify working-directory: node run: npm run --silent verify -- "$GITHUB_REF_NAME" --require-npm 11.5.1 @@ -143,7 +145,7 @@ jobs: path: node/pack/ - name: Publish to npm with provenance - run: npm publish node/pack/*.tgz --provenance --access public + run: npm publish node/pack/*.tgz --provenance --access public --tag "${{ steps.verify.outputs.dist_tag }}" # Retries for ~13 minutes (20 x 40s): the registry lags behind a publish. - name: Smoke-test the published package diff --git a/node/scripts/verify.mjs b/node/scripts/verify.mjs index 141c100..def8241 100644 --- a/node/scripts/verify.mjs +++ b/node/scripts/verify.mjs @@ -13,7 +13,7 @@ // and exits non-zero on the first failure. import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { appendFileSync, readFileSync } from "node:fs"; import { argv, env, exit, platform, stdout } from "node:process"; import { URL } from "node:url"; @@ -25,9 +25,20 @@ function fail(message) { 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] = [a, b].map((value) => value.split(".").map(Number)); + 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) { @@ -40,6 +51,9 @@ function olderThan(a, b) { 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 ?? ""; @@ -63,6 +77,15 @@ if (packageJson.private === true) { ); } +const prerelease = version.split("-")[1]; +if (prerelease !== undefined && !/^rc\.\d+$/.test(prerelease)) { + fail( + `Version ${version} has prerelease suffix "-${prerelease}". Only release ` + + "versions (X.Y.Z) and release candidates (X.Y.Z-rc.N) are published.", + ); +} +const distTag = prerelease === undefined ? "latest" : "next"; + const repositoryUrl = typeof packageJson.repository === "string" ? packageJson.repository @@ -94,4 +117,7 @@ if (requiredNpm !== undefined) { } } -stdout.write(`Publishing version ${version}.\n`); +if (env.GITHUB_OUTPUT) { + appendFileSync(env.GITHUB_OUTPUT, `dist_tag=${distTag}\n`); +} +stdout.write(`Publishing version ${version} with dist-tag ${distTag}.\n`); diff --git a/node/test/verify.test.ts b/node/test/verify.test.ts index a3c3c28..a71842f 100644 --- a/node/test/verify.test.ts +++ b/node/test/verify.test.ts @@ -3,7 +3,14 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -26,6 +33,7 @@ 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"); @@ -38,7 +46,7 @@ function runVerify( writeFileSync(join(temporaryRoot, "CHANGELOG.md"), changelog); return spawnSync(process.execPath, [temporaryScript, ...args], { encoding: "utf8", - env: { ...process.env, GITHUB_REF_NAME: "" }, + env: { ...process.env, ...env }, }); } finally { rmSync(temporaryRoot, { recursive: true, force: true }); @@ -49,28 +57,15 @@ 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\./); + assert.match(result.stdout, /Publishing version 1\.2\.3 with dist-tag latest\./); }); test("reads the tag from GITHUB_REF_NAME when no argument is given", () => { - const temporaryRoot = mkdtempSync(join(tmpdir(), "elasticache-node-verify-")); - try { - mkdirSync(join(temporaryRoot, "scripts")); - copyFileSync(SCRIPT_PATH, join(temporaryRoot, "scripts", "verify.mjs")); - writeFileSync(join(temporaryRoot, "package.json"), JSON.stringify(RELEASABLE)); - writeFileSync(join(temporaryRoot, "CHANGELOG.md"), CHANGELOG_WITH_ENTRY); - const result = spawnSync( - process.execPath, - [join(temporaryRoot, "scripts", "verify.mjs")], - { - encoding: "utf8", - env: { ...process.env, GITHUB_REF_NAME: "node-v1.2.3" }, - }, - ); - assert.equal(result.status, 0, result.stdout + result.stderr); - } finally { - rmSync(temporaryRoot, { recursive: true, force: true }); - } + 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: { @@ -111,6 +106,38 @@ const rejectedCases: { changelog: "# Changelog\n\n## [Unreleased]\n\n- Something.\n", expectedError: /CHANGELOG\.md has no "## \[1\.2\.3\]" entry/, }, + { + name: "rejects a prerelease that is not a release candidate", + args: ["node-v1.2.3-beta.1"], + packageJson: { ...RELEASABLE, version: "1.2.3-beta.1" }, + changelog: "# Changelog\n\n## [1.2.3-beta.1] - 2026-01-01\n\n- Something.\n", + expectedError: /Only release versions .* and release candidates/, + }, + { + 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: /prerelease suffix "-dev.0"/, + }, + { + 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"], @@ -129,6 +156,39 @@ for (const testCase of rejectedCases) { }); } +const RC = { ...RELEASABLE, version: "1.2.3-rc.1" }; +const RC_CHANGELOG = "# Changelog\n\n## [1.2.3-rc.1] - 2026-01-01\n\n- Something.\n"; + +test("publishes a release under the latest dist-tag", () => { + const result = runVerify(["node-v1.2.3"], RELEASABLE); + + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.match(result.stdout, /with dist-tag latest\./); +}); + +test("publishes a release candidate under the next dist-tag", () => { + const result = runVerify(["node-v1.2.3-rc.1"], RC, RC_CHANGELOG); + + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.match(result.stdout, /with dist-tag next\./); +}); + +test("writes the dist-tag to GITHUB_OUTPUT when set", () => { + const outputDirectory = mkdtempSync(join(tmpdir(), "elasticache-gh-output-")); + const outputFile = join(outputDirectory, "out"); + try { + const result = runVerify(["node-v1.2.3-rc.1"], RC, RC_CHANGELOG, { + GITHUB_REF_NAME: "", + GITHUB_OUTPUT: outputFile, + }); + + assert.equal(result.status, 0, result.stdout + result.stderr); + assert.equal(readFileSync(outputFile, "utf8"), "dist_tag=next\n"); + } finally { + rmSync(outputDirectory, { recursive: true, force: true }); + } +}); + test("accepts an npm at or above the required version", () => { const result = runVerify(["node-v1.2.3", "--require-npm", "1.0.0"], RELEASABLE); From 284e3e62b83d58ae8a0afd5e01de43c173a23f6e Mon Sep 17 00:00:00 2001 From: mwaisaida-13 Date: Thu, 24 Sep 2026 23:47:17 +0100 Subject: [PATCH 29/40] ci(python): accept release candidates in the release gate verify accepts X.Y.Z and X.Y.ZrcN (PEP 440) and rejects any other suffix (.devN, aN, bN). pip skips pre-releases unless one is requested explicitly, so an RC on PyPI never reaches a plain pip install and needs no separate channel. --- scripts/python_dev.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/scripts/python_dev.py b/scripts/python_dev.py index ee6633b..e21c1c2 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" +# Release versions (X.Y.Z) and release candidates (X.Y.ZrcN, PEP 440). +_PUBLISHABLE_VERSION = re.compile(r"\d+\.\d+\.\d+(rc\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 _PUBLISHABLE_VERSION.fullmatch(pkg_version): + print( + f"::error::Version {pkg_version} is not publishable. Only release versions " + "(X.Y.Z) and release candidates (X.Y.ZrcN) 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 " @@ -160,7 +168,8 @@ def verify(args: argparse.Namespace) -> int: if drift: print(f"::error::{drift}") return 1 - print(f"Publishing version {pkg_version}.") + kind = "release candidate" if "rc" in pkg_version else "release" + print(f"Publishing version {pkg_version} ({kind}).") return 0 From 32397e99e6a448ee375fd2b59a7b7ddfd5eaf731 Mon Sep 17 00:00:00 2001 From: mwaisaida-13 Date: Fri, 25 Sep 2026 01:05:31 +0100 Subject: [PATCH 30/40] ci: reject prerelease versions in the Node and Python release gates npm refuses to publish a prerelease version without an explicit dist-tag, so verify.mjs now fails on any version with a prerelease suffix and the publish step no longer needs a computed --tag. The Python gate applies the same rule (X.Y.Z only). Prereleases are never required before a release. --- .github/workflows/node-release.yml | 4 +-- node/scripts/verify.mjs | 19 ++++------ node/test/verify.test.ts | 56 +++++------------------------- scripts/python_dev.py | 13 ++++--- 4 files changed, 22 insertions(+), 70 deletions(-) diff --git a/.github/workflows/node-release.yml b/.github/workflows/node-release.yml index dcf6106..22e9fe6 100644 --- a/.github/workflows/node-release.yml +++ b/.github/workflows/node-release.yml @@ -132,9 +132,7 @@ jobs: # The newest supported Node bundles npm >= 11.5.1; assert it rather than install # npm from the registry at publish time. - # Also decides the dist-tag: `latest` for releases, `next` for X.Y.Z-rc.N. - name: Verify again on the publishing runner, including the npm version - id: verify working-directory: node run: npm run --silent verify -- "$GITHUB_REF_NAME" --require-npm 11.5.1 @@ -145,7 +143,7 @@ jobs: path: node/pack/ - name: Publish to npm with provenance - run: npm publish node/pack/*.tgz --provenance --access public --tag "${{ steps.verify.outputs.dist_tag }}" + run: npm publish node/pack/*.tgz --provenance --access public # Retries for ~13 minutes (20 x 40s): the registry lags behind a publish. - name: Smoke-test the published package diff --git a/node/scripts/verify.mjs b/node/scripts/verify.mjs index def8241..f63d3b2 100644 --- a/node/scripts/verify.mjs +++ b/node/scripts/verify.mjs @@ -6,14 +6,15 @@ // 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 CHANGELOG.md has an entry for the -// version, and that package.json declares the repository (npm provenance requires it). +// 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 { appendFileSync, readFileSync } from "node:fs"; +import { readFileSync } from "node:fs"; import { argv, env, exit, platform, stdout } from "node:process"; import { URL } from "node:url"; @@ -77,14 +78,11 @@ if (packageJson.private === true) { ); } -const prerelease = version.split("-")[1]; -if (prerelease !== undefined && !/^rc\.\d+$/.test(prerelease)) { +if (version.includes("-")) { fail( - `Version ${version} has prerelease suffix "-${prerelease}". Only release ` + - "versions (X.Y.Z) and release candidates (X.Y.Z-rc.N) are published.", + `Version ${version} is a prerelease. Only release versions (X.Y.Z) are published.`, ); } -const distTag = prerelease === undefined ? "latest" : "next"; const repositoryUrl = typeof packageJson.repository === "string" @@ -117,7 +115,4 @@ if (requiredNpm !== undefined) { } } -if (env.GITHUB_OUTPUT) { - appendFileSync(env.GITHUB_OUTPUT, `dist_tag=${distTag}\n`); -} -stdout.write(`Publishing version ${version} with dist-tag ${distTag}.\n`); +stdout.write(`Publishing version ${version}.\n`); diff --git a/node/test/verify.test.ts b/node/test/verify.test.ts index a71842f..261bf2f 100644 --- a/node/test/verify.test.ts +++ b/node/test/verify.test.ts @@ -3,14 +3,7 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { - copyFileSync, - mkdirSync, - mkdtempSync, - readFileSync, - rmSync, - writeFileSync, -} from "node:fs"; +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"; @@ -57,7 +50,7 @@ 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 with dist-tag latest\./); + assert.match(result.stdout, /Publishing version 1\.2\.3\./); }); test("reads the tag from GITHUB_REF_NAME when no argument is given", () => { @@ -107,18 +100,18 @@ const rejectedCases: { expectedError: /CHANGELOG\.md has no "## \[1\.2\.3\]" entry/, }, { - name: "rejects a prerelease that is not a release candidate", - args: ["node-v1.2.3-beta.1"], - packageJson: { ...RELEASABLE, version: "1.2.3-beta.1" }, - changelog: "# Changelog\n\n## [1.2.3-beta.1] - 2026-01-01\n\n- Something.\n", - expectedError: /Only release versions .* and release candidates/, + 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: /prerelease suffix "-dev.0"/, + expectedError: /0\.1\.0-dev\.0 is a prerelease/, }, { name: "rejects --require-npm without a value", @@ -156,39 +149,6 @@ for (const testCase of rejectedCases) { }); } -const RC = { ...RELEASABLE, version: "1.2.3-rc.1" }; -const RC_CHANGELOG = "# Changelog\n\n## [1.2.3-rc.1] - 2026-01-01\n\n- Something.\n"; - -test("publishes a release under the latest dist-tag", () => { - const result = runVerify(["node-v1.2.3"], RELEASABLE); - - assert.equal(result.status, 0, result.stdout + result.stderr); - assert.match(result.stdout, /with dist-tag latest\./); -}); - -test("publishes a release candidate under the next dist-tag", () => { - const result = runVerify(["node-v1.2.3-rc.1"], RC, RC_CHANGELOG); - - assert.equal(result.status, 0, result.stdout + result.stderr); - assert.match(result.stdout, /with dist-tag next\./); -}); - -test("writes the dist-tag to GITHUB_OUTPUT when set", () => { - const outputDirectory = mkdtempSync(join(tmpdir(), "elasticache-gh-output-")); - const outputFile = join(outputDirectory, "out"); - try { - const result = runVerify(["node-v1.2.3-rc.1"], RC, RC_CHANGELOG, { - GITHUB_REF_NAME: "", - GITHUB_OUTPUT: outputFile, - }); - - assert.equal(result.status, 0, result.stdout + result.stderr); - assert.equal(readFileSync(outputFile, "utf8"), "dist_tag=next\n"); - } finally { - rmSync(outputDirectory, { recursive: true, force: true }); - } -}); - test("accepts an npm at or above the required version", () => { const result = runVerify(["node-v1.2.3", "--require-npm", "1.0.0"], RELEASABLE); diff --git a/scripts/python_dev.py b/scripts/python_dev.py index e21c1c2..dda2ef5 100755 --- a/scripts/python_dev.py +++ b/scripts/python_dev.py @@ -36,8 +36,8 @@ PACKAGE = "developer-toolkit-elasticache" IMPORT_NAME = "developer_toolkit_elasticache" DO_NOT_UPLOAD = "Private :: Do Not Upload" -# Release versions (X.Y.Z) and release candidates (X.Y.ZrcN, PEP 440). -_PUBLISHABLE_VERSION = re.compile(r"\d+\.\d+\.\d+(rc\d+)?") +# Only release versions (X.Y.Z) are published; no prereleases. +_RELEASE_VERSION = re.compile(r"\d+\.\d+\.\d+") _COMMANDS = { @@ -152,10 +152,10 @@ def verify(args: argparse.Namespace) -> int: f"({pkg_version}). Bump the version and re-tag." ) return 1 - if not _PUBLISHABLE_VERSION.fullmatch(pkg_version): + if not _RELEASE_VERSION.fullmatch(pkg_version): print( - f"::error::Version {pkg_version} is not publishable. Only release versions " - "(X.Y.Z) and release candidates (X.Y.ZrcN) are published." + 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", []): @@ -168,8 +168,7 @@ def verify(args: argparse.Namespace) -> int: if drift: print(f"::error::{drift}") return 1 - kind = "release candidate" if "rc" in pkg_version else "release" - print(f"Publishing version {pkg_version} ({kind}).") + print(f"Publishing version {pkg_version}.") return 0 From caff77612f47f8d735539ac804a82bdcaf612934 Mon Sep 17 00:00:00 2001 From: "Alexandre [C] Luc Rehnby-Martin" Date: Fri, 25 Sep 2026 15:34:59 -0700 Subject: [PATCH 31/40] Address forced refresh review feedback --- java/CHANGELOG.md | 3 ++- java/README.md | 12 ++++++------ .../integration/PublicApiTest.java | 19 ++++++++++++++----- .../auth/ElastiCacheIamAuthTokenManager.java | 15 +++++---------- .../ElastiCacheIamAuthTokenManagerTest.java | 11 ++++++----- 5 files changed, 33 insertions(+), 27 deletions(-) diff --git a/java/CHANGELOG.md b/java/CHANGELOG.md index fee183c..ce507f8 100644 --- a/java/CHANGELOG.md +++ b/java/CHANGELOG.md @@ -19,7 +19,8 @@ and this package uses [Semantic Versioning](https://semver.org/). background before expiry, retrying transient failures, and notifying a token-change callback. - `ElastiCacheIamAuthTokenManager.refreshToken()` for replacing a rejected cached - token immediately while sharing concurrent and background refresh work. + token without waiting for scheduled refresh while sharing concurrent and + background refresh work. - `TokenRefreshException` for closed managers and refreshes that cannot replace an expired token. - Maven build, Java version CI matrix, documentation, and dependency updates. diff --git a/java/README.md b/java/README.md index 90dd39f..a30683d 100644 --- a/java/README.md +++ b/java/README.md @@ -119,13 +119,13 @@ to receive that token and another refresh is scheduled. If the token expires before it can be replaced, `getToken()` throws `TokenRefreshException` with the last refresh failure as its cause. -Call `refreshToken()` after a client rejects the cached token, instead of waiting -for the scheduled refresh. It invalidates that token immediately, resolves AWS -credentials again, signs and installs a replacement, and returns it. Concurrent +Call `refreshToken()` after a client rejects the cached token. It invalidates that +token and refreshes without waiting for the scheduled refresh, resolving AWS +credentials again before signing and installing a replacement. Concurrent `refreshToken()`, `getToken()`, and `getCredentials()` calls share the same -replacement operation, including a background refresh already in progress. If the -replacement cannot be signed, the rejected token remains invalidated and the -failure is reported to the caller. +replacement operation, including a background refresh already in progress or in +retry backoff. If the replacement cannot be signed, the rejected token remains +invalidated and the failure is reported to the caller. The refresh thread is a daemon and does not keep the JVM alive. Close the manager to cancel background work; token requests after close throw 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 index 8b6f7f5..bb87fcf 100644 --- 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 @@ -8,9 +8,9 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.lang.reflect.Method; - 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; @@ -20,11 +20,20 @@ class PublicApiTest { @Test - void installedArtifactExposesPublicApi() throws Exception { + void installedArtifactExposesPublicApi() { assertNotNull(ElastiCacheIamAuthTokenProvider.builder()); assertNotNull(ElastiCacheIamAuthTokenManager.builder()); - Method refreshToken = ElastiCacheIamAuthTokenManager.class.getMethod("refreshToken"); - assertTrue(refreshToken.getReturnType().equals(String.class)); + 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)); diff --git a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java index 07ec66b..2f70e9d 100644 --- a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java +++ b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java @@ -158,11 +158,12 @@ public String getToken() { } /** - * Forces an immediate token 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. + * 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 @@ -311,20 +312,14 @@ private void scheduleRetry( private void finishFailedRefresh( CompletableFuture refresh, RuntimeException lastError) { - String validToken = null; synchronized (lock) { long now = currentTimeMillis.getAsLong(); if (cached != null && now < cached.expiresAt) { - validToken = cached.token; retryNotBefore = now + MAX_DELAY_MILLIS; scheduleRefreshLocked(MAX_DELAY_MILLIS); + refresh.complete(cached.token); + return; } - } - if (validToken != null) { - refresh.complete(validToken); - return; - } - synchronized (lock) { if (cached == null) { refresh.completeExceptionally(lastError); } else { diff --git a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java index 9810ced..9e44b00 100644 --- a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java +++ b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManagerTest.java @@ -16,6 +16,7 @@ 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; @@ -119,7 +120,7 @@ void returnsNormalizedCredentialsAndCachedToken() { } @Test - void forcedRefreshImmediatelyUsesRotatedCredentialsAndResetsScheduling() { + void forcedRefreshUsesRotatedCredentialsAndResetsScheduling() { Harness harness = new Harness(); try { String first = harness.manager.getToken(); @@ -130,7 +131,7 @@ void forcedRefreshImmediatelyUsesRotatedCredentialsAndResetsScheduling() { assertNotEquals(first, replacement); assertEquals(replacement, harness.manager.getToken()); assertEquals(2, harness.credentialCalls.get()); - assertEquals(java.util.Arrays.asList(first, replacement), harness.changedTokens); + assertEquals(Arrays.asList(first, replacement), harness.changedTokens); assertTrue(originalRefresh.cancelled); assertEquals(300_000, harness.scheduler.lastCreated().delayMillis); harness.scheduler.advance(299_999); @@ -265,7 +266,7 @@ void failedForcedRefreshDoesNotReturnInvalidatedToken() { Throwable concurrentFailure = concurrent.join(); assertTrue(forcedFailure instanceof ConfigurationException); assertSame(forcedFailure, concurrentFailure); - assertNotEquals(rejected, forcedFailure.getMessage()); + assertFalse(forcedFailure.getMessage().contains(rejected)); assertEquals(9, credentialCalls.get()); failing.set(false); @@ -341,7 +342,7 @@ void retriesInitialRefreshWithCappedExponentialBackoff() { assertTrue(result.join() instanceof ConfigurationException); assertEquals(8, harness.credentialCalls.get()); assertEquals( - java.util.Arrays.asList(100L, 200L, 400L, 800L, 1_600L, 3_200L, 5_000L), + Arrays.asList(100L, 200L, 400L, 800L, 1_600L, 3_200L, 5_000L), harness.scheduler.delays()); harness.manager.close(); } @@ -684,7 +685,7 @@ void closeAfterSigningPreventsTokenInstallationAndNotification() { @Test void validatesRefreshInterval() { - for (Duration value : java.util.Arrays.asList( + for (Duration value : Arrays.asList( Duration.ZERO, Duration.ofMillis(-1), Duration.ofNanos(1), From cec8be1fc78b591dbcabe1ed03f86b8eb6c4e3e8 Mon Sep 17 00:00:00 2001 From: mwaisaida-13 Date: Mon, 28 Sep 2026 15:03:29 +0100 Subject: [PATCH 32/40] ci(java): add the Maven Central release workflow - pom.xml: add the developer and scm metadata Maven Central requires, and a release profile that signs every artifact and publishes through the Central Portal. Normal builds never sign. - java_dev.py: add verify (tag matches a non-SNAPSHOT pom version with a CHANGELOG entry) and smoke (polls Maven Central for the published files). - java-release.yml: on a java-v* release, test every supported Java version, then sign and publish from the maven environment and smoke-test Maven Central. --- .github/workflows/java-release.yml | 109 +++++++++++++++++++++++++++++ java/pom.xml | 55 +++++++++++++++ scripts/java_dev.py | 77 ++++++++++++++++++-- 3 files changed, 237 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/java-release.yml diff --git a/.github/workflows/java-release.yml b/.github/workflows/java-release.yml new file mode 100644 index 0000000..0c3ff51 --- /dev/null +++ b/.github/workflows/java-release.yml @@ -0,0 +1,109 @@ +name: Publish Java package to Maven Central + +# A java-v* release tests every supported Java version, then signs the artifacts and +# publishes them to Maven Central through the Central Portal, after approval. + +on: + release: + types: [published] + +permissions: + contents: read + +# cancel-in-progress: false — cancelling mid-upload can leave a deployment half done. +concurrency: + group: java-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + # Source of truth for the test matrix: java.supported.versions in pom.xml. + # java-v* releases only — test/publish inherit this via `needs`. + versions: + name: java versions + if: startsWith(github.ref_name, 'java-v') + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + data: ${{ steps.set.outputs.data }} + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Load supported versions from pom.xml + id: set + run: echo "data=$(python3 scripts/java_dev.py list-supported-versions)" >> "$GITHUB_OUTPUT" + + # Full support matrix gates the release. Ubuntu only: the CI workflow already covers + # Windows and macOS. No dependency caching in release jobs. + test: + name: test (Java ${{ matrix.java-version }}) + needs: versions + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false # report every version, not just the first failure + matrix: + java-version: ${{ fromJson(needs.versions.outputs.data).all }} + defaults: + run: + working-directory: java + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Set up Java ${{ matrix.java-version }} + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + with: + distribution: corretto + java-version: ${{ matrix.java-version }} + + - name: Build and test + run: mvn --batch-mode --no-transfer-progress install + + - name: Run installed-artifact integration tests + run: mvn --batch-mode --no-transfer-progress --file integration-tests/pom.xml verify + + publish: + name: publish to Maven Central + needs: [versions, test] + # A fork that copies this workflow and its tags cannot publish. + if: startsWith(github.ref_name, 'java-v') && github.repository_owner == 'aws' + runs-on: ubuntu-latest + # Publishing can take up to 60 minutes and the smoke test up to ~45. + timeout-minutes: 120 + # Holds the Central token and signing key. Its required-reviewer gate is the + # approval to publish. + environment: maven + steps: + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # Tag must match the pom version, which must not be a SNAPSHOT, and the + # CHANGELOG must have an entry. + - name: Verify the tag matches the pom version + run: python3 scripts/java_dev.py verify "$GITHUB_REF_NAME" + + # Writes a settings.xml server `central` that reads its credentials from the + # environment variables named below, and imports the signing key. + - name: Set up Java with the Central credentials and signing key + uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + with: + distribution: corretto + java-version: ${{ fromJson(needs.versions.outputs.data).max }} + server-id: central + server-username: CENTRAL_TOKEN_USERNAME + server-password: CENTRAL_TOKEN_PASSWORD + gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} + + # Tests already ran in the test job. The plugin uploads, waits for validation, + # publishes, and fails this step if any of that fails. + - name: Sign and publish to Maven Central + env: + CENTRAL_TOKEN_USERNAME: ${{ secrets.CENTRAL_TOKEN_USERNAME }} + CENTRAL_TOKEN_PASSWORD: ${{ secrets.CENTRAL_TOKEN_PASSWORD }} + MAVEN_GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + run: mvn --batch-mode --no-transfer-progress --file java/pom.xml -P release -DskipTests deploy + + # Retries for up to ~45 minutes: Maven Central lags behind a publish. + - name: Smoke-test Maven Central + run: python3 scripts/java_dev.py smoke "${GITHUB_REF_NAME#java-v}" diff --git a/java/pom.xml b/java/pom.xml index 353bf6d..a3ca33f 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -23,6 +23,17 @@ + + + Amazon Web Services + + + + + scm:git:https://github.com/aws/developer-toolkit-elasticache.git + https://github.com/aws/developer-toolkit-elasticache/tree/main/java + + UTF-8 8 @@ -178,4 +189,48 @@ + + + + + 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/scripts/java_dev.py b/scripts/java_dev.py index 1162329..2959c35 100644 --- a/scripts/java_dev.py +++ b/scripts/java_dev.py @@ -1,14 +1,36 @@ # 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" 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: @@ -25,20 +47,67 @@ def list_supported_versions() -> None: 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" - ) + 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.") + 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}" + ) + files = [".pom", ".jar", "-sources.jar", "-javadoc.jar", ".jar.asc"] + 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() - parser.add_argument("command", choices=["list-supported-versions"]) + 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__": From 4facd385e29cbda1e363cf92a7f56d5e1846f158 Mon Sep 17 00:00:00 2001 From: mwaisaida-13 Date: Mon, 28 Sep 2026 15:10:19 +0100 Subject: [PATCH 33/40] ci(java): check dependent pom versions and complete developer metadata verify now also requires java/examples/pom.xml and java/integration-tests/pom.xml to depend on the release version, since the examples are not built by the release workflow. The developers block uses the same fields as other AWS libraries on Maven Central. --- java/pom.xml | 5 +++++ scripts/java_dev.py | 16 ++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/java/pom.xml b/java/pom.xml index a3ca33f..8406856 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -25,7 +25,12 @@ + amazonwebservices Amazon Web Services + https://aws.amazon.com + + developer + diff --git a/scripts/java_dev.py b/scripts/java_dev.py index 2959c35..2371747 100644 --- a/scripts/java_dev.py +++ b/scripts/java_dev.py @@ -14,6 +14,10 @@ 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" @@ -60,6 +64,18 @@ def verify(tag: str) -> None: 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}.") From c5ad45188126199b306d633423cf23d4ae2ace85 Mon Sep 17 00:00:00 2001 From: mwaisaida-13 Date: Mon, 28 Sep 2026 17:43:08 +0100 Subject: [PATCH 34/40] ci(java): address review comments - setup-java's gpg-passphrase names MAVEN_GPG_PASSPHRASE explicitly, so the settings.xml it writes and maven-gpg-plugin read the same variable. - The smoke test requires the signature of every artifact, not just the jar. --- .github/workflows/java-release.yml | 3 ++- scripts/java_dev.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/java-release.yml b/.github/workflows/java-release.yml index 0c3ff51..195c2e3 100644 --- a/.github/workflows/java-release.yml +++ b/.github/workflows/java-release.yml @@ -83,7 +83,7 @@ jobs: - name: Verify the tag matches the pom version run: python3 scripts/java_dev.py verify "$GITHUB_REF_NAME" - # Writes a settings.xml server `central` that reads its credentials from the + # Writes a settings.xml whose `central` server and GPG passphrase read the # environment variables named below, and imports the signing key. - name: Set up Java with the Central credentials and signing key uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 @@ -94,6 +94,7 @@ jobs: server-username: CENTRAL_TOKEN_USERNAME server-password: CENTRAL_TOKEN_PASSWORD gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} + gpg-passphrase: MAVEN_GPG_PASSPHRASE # Tests already ran in the test job. The plugin uploads, waits for validation, # publishes, and fails this step if any of that fails. diff --git a/scripts/java_dev.py b/scripts/java_dev.py index 2371747..62587d5 100644 --- a/scripts/java_dev.py +++ b/scripts/java_dev.py @@ -85,7 +85,8 @@ def smoke(version: str) -> None: base = ( f"{CENTRAL}/{group.replace('.', '/')}/{artifact}/{version}/{artifact}-{version}" ) - files = [".pom", ".jar", "-sources.jar", "-javadoc.jar", ".jar.asc"] + 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: From f3ce8a33116b7691cbb5492eb0322b04fe801759 Mon Sep 17 00:00:00 2001 From: Shoham Elias Date: Mon, 12 Oct 2026 12:52:37 +0000 Subject: [PATCH 35/40] feat(python): accept a credentials object for token signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets a caller sign with a credentials object it already holds — the one behind an existing boto3 client, for instance — instead of a session. Credentials take precedence over the session and are still re-read per token, so rotation is preserved. --- .../token_generator.py | 35 ++++++---- .../token_manager.py | 7 +- python/tests/test_token_generator.py | 69 ++++++++++++++++++- 3 files changed, 96 insertions(+), 15 deletions(-) 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 index 315837e..016bd01 100644 --- a/python/src/developer_toolkit_elasticache/token_manager.py +++ b/python/src/developer_toolkit_elasticache/token_manager.py @@ -16,10 +16,13 @@ from collections.abc import Callable from concurrent.futures import Future from dataclasses import dataclass, field -from typing import Protocol +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, @@ -124,6 +127,7 @@ def __init__( # noqa: PLR0913 # the provider's keyword-only arguments plus man 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, ): @@ -133,6 +137,7 @@ def __init__( # noqa: PLR0913 # the provider's keyword-only arguments plus man 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 diff --git a/python/tests/test_token_generator.py b/python/tests/test_token_generator.py index 849b48e..41b007e 100644 --- a/python/tests/test_token_generator.py +++ b/python/tests/test_token_generator.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch import pytest -from botocore.credentials import Credentials +from botocore.credentials import Credentials, RefreshableCredentials from helpers import ( CACHE, REGION, @@ -501,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() From 3dad8c9e726bfff4614883734abdc724195240b4 Mon Sep 17 00:00:00 2001 From: Shoham Elias Date: Tue, 13 Oct 2026 11:30:07 +0000 Subject: [PATCH 36/40] docs(python): document the credentials parameter --- python/CHANGELOG.md | 5 +++++ python/README.md | 21 +++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index 55e2d17..f73ac1f 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -20,6 +20,11 @@ All notable changes to the Python package are documented here. The format follow 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] - Unreleased diff --git a/python/README.md b/python/README.md index 8bdf336..7ee5ed6 100644 --- a/python/README.md +++ b/python/README.md @@ -73,6 +73,27 @@ 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. From 2d29810edcbe88fe8d4b3b6a69da97e0df5cf785 Mon Sep 17 00:00:00 2001 From: mwaisaida-13 Date: Wed, 21 Oct 2026 17:48:23 +0100 Subject: [PATCH 37/40] dependabot: drop the duplicate pip entry for python/requirements The /python entry already scans python/requirements/dev.txt, so the second entry opened a duplicate PR for every tooling bump (for example #21 and #22). --- .github/dependabot.yml | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 2db165e..cdaf351 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,7 +12,10 @@ updates: actions: patterns: ["*"] - # Python runtime/dev deps in python/pyproject.toml. + # Python: runtime deps in python/pyproject.toml and the pinned dev/build/publish + # tooling in python/requirements/dev.txt. One entry covers both, because Dependabot + # scans a directory recursively for pyproject.toml and requirements *.txt files; a + # second entry for python/requirements produced a duplicate PR for every bump. - package-ecosystem: pip directory: /python schedule: @@ -21,16 +24,6 @@ updates: python: patterns: ["*"] - # Pinned dev/build/publish tooling in python/requirements/dev.txt (ruff, pytest, - # build, twine). Dependabot discovers the *.txt files in this directory. - - package-ecosystem: pip - directory: /python/requirements - schedule: - interval: weekly - groups: - tooling: - patterns: ["*"] - # Node runtime/dev dependencies in node/package.json. - package-ecosystem: npm directory: /node From 89fa8168eca06830dcf269ccbff5638317356ef9 Mon Sep 17 00:00:00 2001 From: mwaisaida-13 Date: Wed, 21 Oct 2026 17:09:48 +0100 Subject: [PATCH 38/40] Release Java 1.0.0 Set the version to 1.0.0 in the library, integration-tests and examples poms and the README, and date the CHANGELOG entry. --- java/CHANGELOG.md | 4 +++- java/README.md | 2 +- java/examples/pom.xml | 4 ++-- java/integration-tests/pom.xml | 4 ++-- java/pom.xml | 2 +- 5 files changed, 9 insertions(+), 7 deletions(-) diff --git a/java/CHANGELOG.md b/java/CHANGELOG.md index ce507f8..646c7d6 100644 --- a/java/CHANGELOG.md +++ b/java/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to the Java package will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this package uses [Semantic Versioning](https://semver.org/). -## [Unreleased] +## [1.0.0] - 2026-10-21 ### Added @@ -33,3 +33,5 @@ and this package uses [Semantic Versioning](https://semver.org/). - Java examples demonstrate Lettuce `RedisCredentialsProvider` integration and the raw TLS/Redis protocol exchange for custom clients and proxies. The wire-level example verifies the endpoint hostname during the TLS handshake. + +[1.0.0]: https://github.com/aws/developer-toolkit-elasticache/releases/tag/java-v1.0.0 diff --git a/java/README.md b/java/README.md index a30683d..2b7ac5d 100644 --- a/java/README.md +++ b/java/README.md @@ -21,7 +21,7 @@ Add the dependency to another Maven project: software.amazon.elasticache developer-toolkit-elasticache - 0.1.0-SNAPSHOT + 1.0.0 ``` diff --git a/java/examples/pom.xml b/java/examples/pom.xml index 05c6ae2..db00e70 100644 --- a/java/examples/pom.xml +++ b/java/examples/pom.xml @@ -10,7 +10,7 @@ software.amazon.elasticache developer-toolkit-elasticache-examples - 0.1.0-SNAPSHOT + 1.0.0 UTF-8 @@ -22,7 +22,7 @@ software.amazon.elasticache developer-toolkit-elasticache - 0.1.0-SNAPSHOT + 1.0.0 io.lettuce diff --git a/java/integration-tests/pom.xml b/java/integration-tests/pom.xml index a6ea3a0..ded6372 100644 --- a/java/integration-tests/pom.xml +++ b/java/integration-tests/pom.xml @@ -10,7 +10,7 @@ software.amazon.elasticache developer-toolkit-elasticache-integration-tests - 0.1.0-SNAPSHOT + 1.0.0 UTF-8 @@ -22,7 +22,7 @@ software.amazon.elasticache developer-toolkit-elasticache - 0.1.0-SNAPSHOT + 1.0.0 org.junit.jupiter diff --git a/java/pom.xml b/java/pom.xml index 8406856..40b626b 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -10,7 +10,7 @@ software.amazon.elasticache developer-toolkit-elasticache - 0.1.0-SNAPSHOT + 1.0.0 Developer Toolkit for Amazon ElastiCache Developer toolkit for Amazon ElastiCache https://github.com/aws/developer-toolkit-elasticache From e54e55cb2023cd44f505e6ce5163f1057a89b9fb Mon Sep 17 00:00:00 2001 From: mwaisaida-13 Date: Wed, 21 Oct 2026 17:09:49 +0100 Subject: [PATCH 39/40] Release Node 1.0.0 Set the version to 1.0.0, remove the private publish guard, and date the CHANGELOG entry. --- node/CHANGELOG.md | 4 +++- node/package-lock.json | 4 ++-- node/package.json | 3 +-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/node/CHANGELOG.md b/node/CHANGELOG.md index bf3c1a1..ca50054 100644 --- a/node/CHANGELOG.md +++ b/node/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to the Node.js package are documented here. The format follo [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the package uses [Semantic Versioning](https://semver.org/). -## [Unreleased] +## [1.0.0] - 2026-10-21 ### Added @@ -19,3 +19,5 @@ All notable changes to the Node.js package are documented here. The format follo - `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/package-lock.json b/node/package-lock.json index 252d1a4..d6d96f1 100644 --- a/node/package-lock.json +++ b/node/package-lock.json @@ -1,12 +1,12 @@ { "name": "@aws/developer-toolkit-elasticache", - "version": "0.1.0-dev.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aws/developer-toolkit-elasticache", - "version": "0.1.0-dev.0", + "version": "1.0.0", "license": "Apache-2.0", "dependencies": { "@aws-sdk/credential-providers": "^3.943.0", diff --git a/node/package.json b/node/package.json index 0b78ec6..bb8cc01 100644 --- a/node/package.json +++ b/node/package.json @@ -1,6 +1,6 @@ { "name": "@aws/developer-toolkit-elasticache", - "version": "0.1.0-dev.0", + "version": "1.0.0", "description": "Developer toolkit for Amazon ElastiCache", "repository": { "type": "git", @@ -10,7 +10,6 @@ "homepage": "https://github.com/aws/developer-toolkit-elasticache/tree/main/node#readme", "bugs": "https://github.com/aws/developer-toolkit-elasticache/issues", "type": "module", - "private": true, "license": "Apache-2.0", "engines": { "node": ">=22" From 51bbbdf6c1084bbebe434cf2c18f9193fe8773fd Mon Sep 17 00:00:00 2001 From: mwaisaida-13 Date: Wed, 21 Oct 2026 17:09:49 +0100 Subject: [PATCH 40/40] Release Python 1.1.0 Set the version to 1.1.0 and date the CHANGELOG entry for the token manager and the credentials parameter. --- python/CHANGELOG.md | 3 ++- python/pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/python/CHANGELOG.md b/python/CHANGELOG.md index dca36f1..8fa20de 100644 --- a/python/CHANGELOG.md +++ b/python/CHANGELOG.md @@ -4,7 +4,7 @@ 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/). -## [Unreleased] +## [1.1.0] - 2026-10-21 ### Added @@ -43,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/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"