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..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,12 +24,29 @@ 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 + # Node runtime/dev dependencies in node/package.json. + - package-ecosystem: npm + directory: /node + schedule: + interval: weekly + groups: + node: + patterns: ["*"] + + # Java runtime and test dependencies in java/pom.xml. + - package-ecosystem: maven + directory: /java + schedule: + interval: weekly + groups: + java: + patterns: ["*"] + + # Standalone Java example build plugins. + - package-ecosystem: maven + directory: /java/examples schedule: interval: weekly groups: - tooling: + java-examples: patterns: ["*"] diff --git a/.github/workflows/java-release.yml b/.github/workflows/java-release.yml new file mode 100644 index 0000000..195c2e3 --- /dev/null +++ b/.github/workflows/java-release.yml @@ -0,0 +1,110 @@ +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 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 + 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 }} + 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. + - 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/.github/workflows/java.yml b/.github/workflows/java.yml new file mode 100644 index 0000000..06d03b4 --- /dev/null +++ b/.github/workflows/java.yml @@ -0,0 +1,79 @@ +name: Java CI + +on: + push: + 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: ${{ fromJson(needs.versions.outputs.data).all }} + include: + - os: windows-latest + java-version: "${{ fromJson(needs.versions.outputs.data).min }}" + - os: windows-latest + java-version: "${{ fromJson(needs.versions.outputs.data).max }}" + - os: macos-14 + java-version: "${{ fromJson(needs.versions.outputs.data).min }}" + - os: macos-14 + java-version: "${{ fromJson(needs.versions.outputs.data).max }}" + + 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 + 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: 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/.github/workflows/node-release.yml b/.github/workflows/node-release.yml new file mode 100644 index 0000000..22e9fe6 --- /dev/null +++ b/.github/workflows/node-release.yml @@ -0,0 +1,150 @@ +name: Publish Node package to npm + +# The npm trusted publisher must reference this exact filename and the `npm` +# environment (https://docs.npmjs.com/trusted-publishers). + +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 jobs, 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 and run the test suite + timeout-minutes: 10 + run: | + npm ci + npm test + + build: + name: build and verify + needs: [versions, test] + if: startsWith(github.ref_name, 'node-v') + runs-on: ubuntu-latest + timeout-minutes: 15 + 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 + + # 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 pack the tarball + working-directory: node + timeout-minutes: 5 + run: | + npm ci + npm run build + 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] + # 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: 20 + # 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 + 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: + node-version: ${{ fromJson(needs.versions.outputs.data).max }} + registry-url: https://registry.npmjs.org + package-manager-cache: false + + # 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: Retrieve the tarball from the build job + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: node-dist + path: node/pack/ + + - 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: python3 scripts/node_dev.py smoke "${GITHUB_REF_NAME#node-v}" 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/.github/workflows/python.yml b/.github/workflows/python.yml index 3e0c6f2..94e9ce2 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" permissions: diff --git a/.gitignore b/.gitignore index 84a6b4b..67969fd 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,10 @@ *.iml .vscode/ -# Language-specific ignores live in python/.gitignore and java/.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/README.md b/README.md index 143d99c..ff71530 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,25 @@ # 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, 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 +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) | +| 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 -## Quick start (Python) +### Python ```bash python3 -m pip install developer-toolkit-elasticache @@ -34,6 +39,42 @@ 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, token caching and background refresh, and connection 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 @@ -42,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/CHANGELOG.md b/java/CHANGELOG.md new file mode 100644 index 0000000..646c7d6 --- /dev/null +++ b/java/CHANGELOG.md @@ -0,0 +1,37 @@ +# 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/). + +## [1.0.0] - 2026-10-21 + +### 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`. +- `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 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. + +### 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. + +[1.0.0]: https://github.com/aws/developer-toolkit-elasticache/releases/tag/java-v1.0.0 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..2b7ac5d --- /dev/null +++ b/java/README.md @@ -0,0 +1,212 @@ +# 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, including optional token +caching and background refresh. + +## Installation + +Requires Java 8 or later and Maven. To build and install the current source into +your local Maven repository: + +```bash +cd java +mvn install +``` + +Add the dependency to another Maven project: + +```xml + + software.amazon.elasticache + developer-toolkit-elasticache + 1.0.0 + +``` + +## 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 +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. 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. + +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. + +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, 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 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 +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. 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 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 +`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 +uses the manager's current token: + +```java +RedisCredentialsProvider credentialsProvider = new RedisCredentialsProvider() { + @Override + public Mono resolveCredentials() { + return Mono.fromSupplier(() -> { + ElastiCacheIamAuthTokenManager.Credentials credentials = + auth.getCredentials(); + return RedisCredentials.just( + credentials.getUserId(), credentials.getToken()); + }); + } +}; +``` + +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: + +```bash +mvn --file examples/pom.xml compile exec:java \ + -Dexec.mainClass=software.amazon.elasticache.examples.ConnectWithLettuce \ + -Dexec.args=" " +``` + +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: + +```bash +mvn --file examples/pom.xml compile exec:java \ + -Dexec.mainClass=software.amazon.elasticache.examples.ConnectServerlessCache \ + -Dexec.args=" " +``` + +The wire-level example enables certificate hostname verification before sending +the IAM token. + +## Security + +The generated token is a bearer credential. Keep it out of logs and connect to the +cache over TLS. + +## License + +Apache-2.0. See [LICENSE](LICENSE). diff --git a/java/examples/pom.xml b/java/examples/pom.xml new file mode 100644 index 0000000..db00e70 --- /dev/null +++ b/java/examples/pom.xml @@ -0,0 +1,51 @@ + + + + 4.0.0 + + software.amazon.elasticache + developer-toolkit-elasticache-examples + 1.0.0 + + + UTF-8 + 8 + 7.7.0.RELEASE + + + + + software.amazon.elasticache + developer-toolkit-elasticache + 1.0.0 + + + io.lettuce + lettuce-core + ${lettuce.version} + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.1 + + ${maven.compiler.release} + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + + + diff --git a/java/examples/src/main/java/software/amazon/elasticache/examples/ConnectServerlessCache.java b/java/examples/src/main/java/software/amazon/elasticache/examples/ConnectServerlessCache.java new file mode 100644 index 0000000..207a528 --- /dev/null +++ b/java/examples/src/main/java/software/amazon/elasticache/examples/ConnectServerlessCache.java @@ -0,0 +1,127 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.elasticache.examples; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; + +import software.amazon.awssdk.regions.Region; +import software.amazon.elasticache.auth.ElastiCacheIamAuthTokenProvider; + +/** + * Connects to an ElastiCache serverless cache over TLS using IAM authentication. + * + *

This example uses the Redis serialization protocol directly so the toolkit's example + * does not require or endorse a particular Redis or Valkey client. Applications should + * normally use their preferred client library and supply {@link + * ElastiCacheIamAuthTokenProvider#getUserId()} and {@link + * ElastiCacheIamAuthTokenProvider#getToken()} when authenticating or reconnecting. + */ +public final class ConnectServerlessCache { + private static final int DEFAULT_PORT = 6379; + + private ConnectServerlessCache() {} + + /** + * Runs the connection example. + * + * @param args cache name, IAM user ID, endpoint, region, and optional port + * @throws IOException if the TLS connection or Redis protocol exchange fails + */ + public static void main(String[] args) throws IOException { + if (args.length < 4 || args.length > 5) { + throw new IllegalArgumentException( + "Usage: ConnectServerlessCache " + + " [port]"); + } + + String cacheName = args[0]; + String userId = args[1]; + String endpoint = args[2]; + Region region = Region.of(args[3]); + int port = args.length == 5 ? Integer.parseInt(args[4]) : DEFAULT_PORT; + + ElastiCacheIamAuthTokenProvider auth = ElastiCacheIamAuthTokenProvider.builder() + .serverlessCacheName(cacheName) + .userId(userId) + .region(region) + .build(); + + SSLSocketFactory socketFactory = (SSLSocketFactory) SSLSocketFactory.getDefault(); + try (SSLSocket socket = (SSLSocket) socketFactory.createSocket(endpoint, port); + BufferedInputStream input = new BufferedInputStream(socket.getInputStream()); + OutputStream output = socket.getOutputStream()) { + enableHostnameVerification(socket); + socket.startHandshake(); + + writeCommand(output, "AUTH", auth.getUserId(), auth.getToken()); + expectSimpleResponse(input, "OK"); + + writeCommand(output, "PING"); + expectSimpleResponse(input, "PONG"); + } + + System.out.println("IAM authentication succeeded; PING returned PONG."); + } + + static void enableHostnameVerification(SSLSocket socket) { + SSLParameters sslParameters = socket.getSSLParameters(); + sslParameters.setEndpointIdentificationAlgorithm("HTTPS"); + socket.setSSLParameters(sslParameters); + } + + private static void writeCommand(OutputStream output, String... arguments) throws IOException { + writeAscii(output, "*" + arguments.length + "\r\n"); + for (String argument : arguments) { + byte[] value = argument.getBytes(StandardCharsets.UTF_8); + writeAscii(output, "$" + value.length + "\r\n"); + output.write(value); + writeAscii(output, "\r\n"); + } + output.flush(); + } + + private static void expectSimpleResponse(BufferedInputStream input, String expected) + throws IOException { + int responseType = input.read(); + if (responseType == -1) { + throw new IOException("ElastiCache closed the connection without a response"); + } + + String response = readLine(input); + if (responseType == '-') { + throw new IOException("ElastiCache returned an error: " + response); + } + if (responseType != '+' || !expected.equals(response)) { + throw new IOException("Unexpected ElastiCache response"); + } + } + + private static String readLine(BufferedInputStream input) throws IOException { + StringBuilder line = new StringBuilder(); + int previous = -1; + while (true) { + int current = input.read(); + if (current == -1) { + throw new IOException("ElastiCache closed the connection mid-response"); + } + if (previous == '\r' && current == '\n') { + line.setLength(line.length() - 1); + return line.toString(); + } + line.append((char) current); + previous = current; + } + } + + private static void writeAscii(OutputStream output, String value) throws IOException { + output.write(value.getBytes(StandardCharsets.US_ASCII)); + } +} diff --git a/java/examples/src/main/java/software/amazon/elasticache/examples/ConnectWithLettuce.java b/java/examples/src/main/java/software/amazon/elasticache/examples/ConnectWithLettuce.java new file mode 100644 index 0000000..b2f26a4 --- /dev/null +++ b/java/examples/src/main/java/software/amazon/elasticache/examples/ConnectWithLettuce.java @@ -0,0 +1,78 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.elasticache.examples; + +import io.lettuce.core.RedisClient; +import io.lettuce.core.RedisCredentials; +import io.lettuce.core.RedisCredentialsProvider; +import io.lettuce.core.RedisURI; +import io.lettuce.core.api.StatefulRedisConnection; +import reactor.core.publisher.Mono; +import software.amazon.awssdk.regions.Region; +import software.amazon.elasticache.auth.ElastiCacheIamAuthTokenManager; + +/** + * Connects to an ElastiCache serverless cache with Lettuce and IAM authentication. + */ +public final class ConnectWithLettuce { + private static final int DEFAULT_PORT = 6379; + + private ConnectWithLettuce() {} + + /** + * Runs the Lettuce connection example. + * + * @param args cache name, IAM user ID, endpoint, region, and optional port + */ + public static void main(String[] args) { + if (args.length < 4 || args.length > 5) { + throw new IllegalArgumentException( + "Usage: ConnectWithLettuce " + + " [port]"); + } + + String cacheName = args[0]; + String userId = args[1]; + String endpoint = args[2]; + Region region = Region.of(args[3]); + int port = args.length == 5 ? Integer.parseInt(args[4]) : DEFAULT_PORT; + + ElastiCacheIamAuthTokenManager auth = ElastiCacheIamAuthTokenManager.builder() + .serverlessCacheName(cacheName) + .userId(userId) + .region(region) + .build(); + + RedisCredentialsProvider credentialsProvider = new RedisCredentialsProvider() { + @Override + public Mono resolveCredentials() { + return Mono.fromSupplier(() -> { + ElastiCacheIamAuthTokenManager.Credentials credentials = + auth.getCredentials(); + return RedisCredentials.just( + credentials.getUserId(), credentials.getToken()); + }); + } + }; + RedisURI redisUri = RedisURI.Builder.redis(endpoint, port) + .withSsl(true) + .withAuthentication(credentialsProvider) + .build(); + + RedisClient client = RedisClient.create(redisUri); + StatefulRedisConnection connection = null; + try { + connection = client.connect(); + System.out.println("IAM authentication succeeded; PING returned " + + connection.sync().ping() + "."); + } finally { + if (connection != null) { + connection.close(); + } + client.shutdown(); + auth.close(); + } + } +} diff --git a/java/integration-tests/pom.xml b/java/integration-tests/pom.xml new file mode 100644 index 0000000..ded6372 --- /dev/null +++ b/java/integration-tests/pom.xml @@ -0,0 +1,61 @@ + + + + 4.0.0 + + software.amazon.elasticache + developer-toolkit-elasticache-integration-tests + 1.0.0 + + + UTF-8 + 8 + 5.13.4 + + + + + software.amazon.elasticache + developer-toolkit-elasticache + 1.0.0 + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.1 + + ${maven.compiler.release} + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + true + + + + + diff --git a/java/integration-tests/src/test/java/software/amazon/elasticache/integration/PublicApiTest.java b/java/integration-tests/src/test/java/software/amazon/elasticache/integration/PublicApiTest.java new file mode 100644 index 0000000..bb87fcf --- /dev/null +++ b/java/integration-tests/src/test/java/software/amazon/elasticache/integration/PublicApiTest.java @@ -0,0 +1,43 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.elasticache.integration; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.regions.Region; +import software.amazon.elasticache.auth.ConfigurationException; +import software.amazon.elasticache.auth.ElastiCacheIamAuthTokenManager; +import software.amazon.elasticache.auth.ElastiCacheIamAuthTokenProvider; +import software.amazon.elasticache.auth.InvalidParameterException; +import software.amazon.elasticache.auth.TokenRefreshException; +import software.amazon.elasticache.auth.ToolkitInputException; + +class PublicApiTest { + @Test + void installedArtifactExposesPublicApi() { + assertNotNull(ElastiCacheIamAuthTokenProvider.builder()); + assertNotNull(ElastiCacheIamAuthTokenManager.builder()); + try (ElastiCacheIamAuthTokenManager manager = + ElastiCacheIamAuthTokenManager.builder() + .serverlessCacheName("test-cache") + .userId("test-user") + .region(Region.US_EAST_1) + .credentialsProvider(() -> + AwsBasicCredentials.create("test-access-key", "test-secret-key")) + .build()) { + String token = manager.refreshToken(); + assertNotNull(token); + } + assertTrue(ToolkitInputException.class.isAssignableFrom(ConfigurationException.class)); + assertTrue(ToolkitInputException.class.isAssignableFrom(InvalidParameterException.class)); + assertFalse(ToolkitInputException.class.isAssignableFrom(TokenRefreshException.class)); + assertFalse( + InvalidParameterException.class.isAssignableFrom(ConfigurationException.class)); + } +} diff --git a/java/pom.xml b/java/pom.xml new file mode 100644 index 0000000..40b626b --- /dev/null +++ b/java/pom.xml @@ -0,0 +1,241 @@ + + + + 4.0.0 + + software.amazon.elasticache + developer-toolkit-elasticache + 1.0.0 + Developer Toolkit for Amazon ElastiCache + Developer toolkit for Amazon ElastiCache + https://github.com/aws/developer-toolkit-elasticache + + + + Apache License, Version 2.0 + https://www.apache.org/licenses/LICENSE-2.0.txt + repo + + + + + + amazonwebservices + Amazon Web Services + https://aws.amazon.com + + developer + + + + + + scm:git:https://github.com/aws/developer-toolkit-elasticache.git + https://github.com/aws/developer-toolkit-elasticache/tree/main/java + + + + UTF-8 + 8 + ["8","11","17","21"] + 2.54.10 + 5.13.4 + + + + + + software.amazon.awssdk + bom + ${aws.sdk.version} + pom + import + + + + + + + software.amazon.awssdk + auth + + + software.amazon.awssdk + sdk-core + + + software.amazon.awssdk + http-client-spi + + + software.amazon.awssdk + http-auth-spi + + + software.amazon.awssdk + identity-spi + + + software.amazon.awssdk + http-auth-aws + + + software.amazon.awssdk + regions + + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test + + + + + + + ${project.basedir} + META-INF + + LICENSE + NOTICE + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.1 + + ${maven.compiler.release} + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.4 + + false + + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + + software.amazon.elasticache.auth + + + + + + org.apache.maven.plugins + maven-source-plugin + 3.3.1 + + + attach-sources + + jar-no-fork + + + + + + org.apache.maven.plugins + maven-javadoc-plugin + 3.11.2 + + + attach-javadocs + + jar + + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.7.0 + + + analyze-dependencies + verify + + analyze-only + + + true + + + + org.junit.jupiter:junit-jupiter-engine + + + + + + + + + + + + + release + + + + org.apache.maven.plugins + maven-gpg-plugin + 3.2.8 + + + sign-artifacts + verify + + sign + + + true + + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.11.0 + true + + central + true + published + + 3600 + ${project.artifactId} ${project.version} + + + + + + + diff --git a/java/src/main/java/software/amazon/elasticache/auth/ConfigurationException.java b/java/src/main/java/software/amazon/elasticache/auth/ConfigurationException.java new file mode 100644 index 0000000..bc46be1 --- /dev/null +++ b/java/src/main/java/software/amazon/elasticache/auth/ConfigurationException.java @@ -0,0 +1,21 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.elasticache.auth; + +/** + * Indicates that the AWS environment is missing configuration required to sign a token. + */ +public final class ConfigurationException extends ToolkitInputException { + private static final long serialVersionUID = 1L; + + /** + * Creates a configuration exception. + * + * @param message actionable error message + */ + public ConfigurationException(String message) { + super(message); + } +} diff --git a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java new file mode 100644 index 0000000..2f70e9d --- /dev/null +++ b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenManager.java @@ -0,0 +1,685 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.elasticache.auth; + +import java.time.Clock; +import java.time.Duration; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; +import java.util.function.DoubleSupplier; +import java.util.function.LongSupplier; +import java.util.function.Supplier; + +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.regions.providers.AwsRegionProvider; +import software.amazon.awssdk.regions.providers.DefaultAwsRegionProviderChain; + +/** + * Caches an ElastiCache IAM authentication token and refreshes it before expiry. + * + *

The first token is generated lazily. Calls reuse the cached token while it + * remains valid, and concurrent callers share a single refresh. Refresh failures + * are retried in the background while a valid cached token continues to be served. + * + *

Call {@link #close()} when the manager is no longer needed. + */ +public final class ElastiCacheIamAuthTokenManager implements AutoCloseable { + private static final Duration DEFAULT_REFRESH_AFTER = Duration.ofMinutes(5); + private static final int MAX_ATTEMPTS = 8; + private static final long BASE_DELAY_MILLIS = 100; + private static final long MAX_DELAY_MILLIS = 5_000; + private static final double JITTER_RATIO = 0.2; + private static final Duration TOKEN_SERVE_MARGIN = Duration.ofSeconds(20); + private static final Duration EFFECTIVE_TOKEN_LIFETIME = + ElastiCacheIamAuthTokenProvider.TOKEN_TTL.minus(TOKEN_SERVE_MARGIN); + private static final String CLOSED_MESSAGE = "The token manager is closed."; + private static final String REFRESH_CANCELLED_MESSAGE = + "Token refresh was cancelled because the token manager was closed."; + private static final String REFRESH_FAILED_MESSAGE = + "Could not refresh the ElastiCache IAM authentication token before " + + "the previous token expired."; + + private final Object lock = new Object(); + private final ElastiCacheIamAuthTokenProvider provider; + private final long refreshAfterMillis; + private final Consumer onTokenChanged; + private final LongSupplier currentTimeMillis; + private final DoubleSupplier random; + private final Scheduler scheduler; + private final boolean ownsScheduler; + private final Runnable beforeInstall; + private final Runnable onSharedRefresh; + + private CachedToken cached; + private CompletableFuture inFlight; + private Cancellable refreshTask; + private Cancellable retryTask; + private long retryNotBefore; + private boolean closed; + + private ElastiCacheIamAuthTokenManager(Builder builder) { + this.provider = ElastiCacheIamAuthTokenProvider.builder() + .userId(builder.userId) + .region(builder.region) + .serverlessCacheName(builder.serverlessCacheName) + .replicationGroupId(builder.replicationGroupId) + .credentialsProvider(builder.credentialsProvider == null + ? DefaultCredentialsProvider.builder().build() + : builder.credentialsProvider) + .awsRegionEnvironmentProvider(builder.awsRegionEnvironmentProvider == null + ? () -> System.getenv("AWS_REGION") + : builder.awsRegionEnvironmentProvider) + .regionProvider(builder.regionProvider == null + ? DefaultAwsRegionProviderChain.builder().build() + : builder.regionProvider) + .signingClock(builder.signingClock == null + ? Clock.systemUTC() + : builder.signingClock) + .build(); + this.refreshAfterMillis = resolveRefreshAfterMillis(builder.refreshAfter); + this.onTokenChanged = builder.onTokenChanged; + this.currentTimeMillis = builder.currentTimeMillis == null + ? System::currentTimeMillis + : builder.currentTimeMillis; + this.random = builder.random == null ? Math::random : builder.random; + this.beforeInstall = builder.beforeInstall; + this.onSharedRefresh = builder.onSharedRefresh; + if (builder.scheduler == null) { + this.scheduler = new DefaultScheduler(); + this.ownsScheduler = true; + } else { + this.scheduler = builder.scheduler; + this.ownsScheduler = false; + } + } + + /** + * Creates a builder for a caching IAM authentication token manager. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Returns the normalized ElastiCache user ID. + * + * @return lowercase ElastiCache user ID + */ + public String getUserId() { + return provider.getUserId(); + } + + /** + * Returns the normalized user ID and a valid token together. + * + * @return credentials suitable for a client credential-provider adapter + */ + public Credentials getCredentials() { + return new Credentials(getUserId(), getToken()); + } + + /** + * Returns a valid token, generating one on the first call. + * + *

While a cached token remains valid it is returned immediately. Once its + * refresh point is reached, refresh proceeds in the background. + * + * @return current IAM authentication token + * @throws TokenRefreshException if the manager is closed or an expired token + * could not be replaced + */ + public String getToken() { + synchronized (lock) { + ensureOpen(); + long now = currentTimeMillis.getAsLong(); + if (cached != null && now < cached.expiresAt) { + String token = cached.token; + if (now >= cached.refreshAt && now >= retryNotBefore) { + scheduleRefreshLocked(0); + } + return token; + } + } + return await(refresh()); + } + + /** + * Forces a token refresh without waiting for the scheduled refresh. + * + *

The currently cached token is invalidated before new AWS credentials are + * resolved and a replacement token is signed. Concurrent token callers share + * the same refresh, including a background refresh already in progress or in + * retry backoff. + * + *

Use this operation after a client rejects the cached token. Discard the + * rejected connection before opening a replacement connection with the + * returned token. + * + * @return newly installed IAM authentication token + * @throws TokenRefreshException if the manager is closed + * @throws ConfigurationException if usable AWS credentials cannot be resolved + */ + public String refreshToken() { + synchronized (lock) { + ensureOpen(); + cached = null; + cancel(refreshTask); + refreshTask = null; + retryNotBefore = 0; + } + return await(refresh()); + } + + /** + * Stops all background work and releases the manager's scheduler. + * + *

Subsequent token requests throw {@link TokenRefreshException}. + */ + @Override + public void close() { + CompletableFuture pending; + synchronized (lock) { + if (closed) { + return; + } + closed = true; + cancel(refreshTask); + refreshTask = null; + cancel(retryTask); + retryTask = null; + pending = inFlight; + } + if (pending != null) { + pending.completeExceptionally( + new TokenRefreshException(REFRESH_CANCELLED_MESSAGE)); + } + if (ownsScheduler) { + scheduler.close(); + } + } + + private CompletableFuture refresh() { + CompletableFuture refresh; + synchronized (lock) { + ensureOpen(); + if (inFlight != null) { + if (onSharedRefresh != null) { + onSharedRefresh.run(); + } + return inFlight; + } + refresh = new CompletableFuture<>(); + inFlight = refresh; + refresh.whenComplete((result, error) -> { + synchronized (lock) { + if (inFlight == refresh) { + inFlight = null; + } + } + }); + } + runRefreshAttempt(refresh, 1, null); + return refresh; + } + + private void startBackgroundRefresh() { + try { + refresh(); + } catch (TokenRefreshException ignored) { + // Closing the manager races safely with a scheduled refresh. + } + } + + private void runRefreshAttempt( + CompletableFuture refresh, int attempt, Throwable lastError) { + synchronized (lock) { + if (closed) { + refresh.completeExceptionally(new TokenRefreshException( + REFRESH_CANCELLED_MESSAGE, lastError)); + return; + } + retryTask = null; + } + + try { + long issuedAt = currentTimeMillis.getAsLong(); + String token = provider.getToken(); + if (beforeInstall != null) { + beforeInstall.run(); + } + installAndComplete(refresh, token, issuedAt); + } catch (Throwable failure) { + if (failure instanceof Error) { + refresh.completeExceptionally(failure); + throw (Error) failure; + } + RuntimeException exception = failure instanceof RuntimeException + ? (RuntimeException) failure + : new RuntimeException(failure); + synchronized (lock) { + if (closed) { + refresh.completeExceptionally( + new TokenRefreshException( + REFRESH_CANCELLED_MESSAGE, exception)); + return; + } + } + if (attempt == MAX_ATTEMPTS) { + finishFailedRefresh(refresh, exception); + return; + } + scheduleRetry(refresh, attempt + 1, exception, backoffDelay(attempt)); + } + } + + private void scheduleRetry( + CompletableFuture refresh, + int nextAttempt, + Throwable lastError, + long delayMillis) { + try { + Cancellable task = scheduler.schedule( + () -> runRefreshAttempt(refresh, nextAttempt, lastError), delayMillis); + synchronized (lock) { + if (closed) { + task.cancel(); + refresh.completeExceptionally( + new TokenRefreshException( + REFRESH_CANCELLED_MESSAGE, lastError)); + } else { + retryTask = task; + } + } + } catch (RejectedExecutionException exception) { + refresh.completeExceptionally( + new TokenRefreshException(REFRESH_CANCELLED_MESSAGE, lastError)); + } + } + + private void finishFailedRefresh( + CompletableFuture refresh, RuntimeException lastError) { + synchronized (lock) { + long now = currentTimeMillis.getAsLong(); + if (cached != null && now < cached.expiresAt) { + retryNotBefore = now + MAX_DELAY_MILLIS; + scheduleRefreshLocked(MAX_DELAY_MILLIS); + refresh.complete(cached.token); + return; + } + if (cached == null) { + refresh.completeExceptionally(lastError); + } else { + refresh.completeExceptionally( + new TokenRefreshException(REFRESH_FAILED_MESSAGE, lastError)); + } + } + } + + private void installAndComplete( + CompletableFuture refresh, String token, long issuedAt) { + synchronized (lock) { + if (closed) { + refresh.completeExceptionally( + new TokenRefreshException(REFRESH_CANCELLED_MESSAGE)); + return; + } + cached = new CachedToken( + token, + issuedAt + refreshAfterMillis, + issuedAt + EFFECTIVE_TOKEN_LIFETIME.toMillis()); + retryNotBefore = 0; + scheduleRefreshLocked(refreshAfterMillis); + refresh.complete(token); + } + notifyTokenChanged(token); + } + + private void notifyTokenChanged(String token) { + if (onTokenChanged == null) { + return; + } + try { + scheduler.execute(() -> { + try { + onTokenChanged.accept(token); + } catch (Throwable ignored) { + // A consumer callback cannot invalidate a successfully signed token. + } + }); + } catch (Throwable ignored) { + // Failure to dispatch a callback cannot invalidate a signed token. + } + } + + private void scheduleRefreshLocked(long delayMillis) { + cancel(refreshTask); + refreshTask = null; + if (closed) { + return; + } + Cancellable[] scheduled = new Cancellable[1]; + scheduled[0] = scheduler.schedule(() -> { + synchronized (lock) { + if (refreshTask == scheduled[0]) { + refreshTask = null; + } + } + startBackgroundRefresh(); + }, delayMillis); + refreshTask = scheduled[0]; + } + + private long backoffDelay(int failedAttempt) { + long exponential = BASE_DELAY_MILLIS << Math.min(failedAttempt - 1, 30); + long delay = Math.min(exponential, MAX_DELAY_MILLIS); + double jitter = 1 - JITTER_RATIO + 2 * JITTER_RATIO * random.getAsDouble(); + return Math.round(delay * jitter); + } + + private void ensureOpen() { + if (closed) { + throw new TokenRefreshException(CLOSED_MESSAGE); + } + } + + private static void cancel(Cancellable task) { + if (task != null) { + task.cancel(); + } + } + + private static String await(CompletableFuture future) { + try { + return future.join(); + } catch (CompletionException exception) { + Throwable cause = exception.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + if (cause instanceof Error) { + throw (Error) cause; + } + throw exception; + } + } + + private static long resolveRefreshAfterMillis(Duration refreshAfter) { + Duration resolved = refreshAfter == null ? DEFAULT_REFRESH_AFTER : refreshAfter; + long millis; + try { + millis = resolved.toMillis(); + } catch (ArithmeticException exception) { + millis = -1; + } + if (resolved.isNegative() + || resolved.isZero() + || millis <= 0 + || resolved.compareTo(EFFECTIVE_TOKEN_LIFETIME) >= 0) { + throw new InvalidParameterException( + "Invalid value for parameter 'refreshAfter': must be at least " + + "1 millisecond and less than 14 minutes 40 seconds."); + } + return millis; + } + + /** + * Immutable username and token pair returned by {@link #getCredentials()}. + */ + public static final class Credentials { + private final String userId; + private final String token; + + private Credentials(String userId, String token) { + this.userId = userId; + this.token = token; + } + + /** + * Returns the normalized ElastiCache user ID. + * + * @return lowercase user ID + */ + public String getUserId() { + return userId; + } + + /** + * Returns the cached IAM authentication token. + * + * @return IAM authentication token + */ + public String getToken() { + return token; + } + } + + /** + * Builder for {@link ElastiCacheIamAuthTokenManager}. + */ + public static final class Builder { + private String userId; + private Region region; + private String serverlessCacheName; + private String replicationGroupId; + private AwsCredentialsProvider credentialsProvider; + private Duration refreshAfter; + private Consumer onTokenChanged; + private Supplier awsRegionEnvironmentProvider; + private AwsRegionProvider regionProvider; + private Clock signingClock; + private LongSupplier currentTimeMillis; + private DoubleSupplier random; + private Scheduler scheduler; + private Runnable beforeInstall; + private Runnable onSharedRefresh; + + private Builder() {} + + /** + * Sets the IAM-enabled ElastiCache user ID. + * + * @param userId ElastiCache user ID + * @return this builder + */ + public Builder userId(String userId) { + this.userId = userId; + return this; + } + + /** + * Sets the signing region. + * + * @param region AWS region + * @return this builder + */ + public Builder region(Region region) { + this.region = region; + return this; + } + + /** + * Sets the serverless cache name. + * + * @param serverlessCacheName serverless cache name + * @return this builder + */ + public Builder serverlessCacheName(String serverlessCacheName) { + this.serverlessCacheName = serverlessCacheName; + return this; + } + + /** + * Sets the node-based replication group ID. + * + * @param replicationGroupId replication group ID + * @return this builder + */ + public Builder replicationGroupId(String replicationGroupId) { + this.replicationGroupId = replicationGroupId; + return this; + } + + /** + * Sets a credentials provider. The standard AWS provider chain is the default. + * + * @param credentialsProvider AWS credentials provider + * @return this builder + */ + public Builder credentialsProvider(AwsCredentialsProvider credentialsProvider) { + this.credentialsProvider = Objects.requireNonNull( + credentialsProvider, "credentialsProvider"); + return this; + } + + /** + * Sets how long after issuance the token is refreshed in the background. + * + *

The default is 5 minutes. The value must be at least 1 millisecond + * and less than the effective 14-minute-40-second token lifetime. The + * effective lifetime reserves 20 seconds for clock skew and transit time. + * + * @param refreshAfter refresh delay after token issuance + * @return this builder + */ + public Builder refreshAfter(Duration refreshAfter) { + this.refreshAfter = Objects.requireNonNull(refreshAfter, "refreshAfter"); + return this; + } + + /** + * Sets a callback invoked on the manager's refresh thread after each newly + * generated token is installed and made available to token callers. + * + *

The callback should return quickly because it delays this manager's + * next background refresh. Callback failures are ignored and do not discard + * the new token. + * + * @param onTokenChanged token-change callback + * @return this builder + */ + public Builder onTokenChanged(Consumer onTokenChanged) { + this.onTokenChanged = Objects.requireNonNull(onTokenChanged, "onTokenChanged"); + return this; + } + + Builder regionProvider(AwsRegionProvider regionProvider) { + this.regionProvider = Objects.requireNonNull(regionProvider, "regionProvider"); + return this; + } + + Builder awsRegionEnvironmentProvider(Supplier provider) { + this.awsRegionEnvironmentProvider = Objects.requireNonNull(provider, "provider"); + return this; + } + + Builder signingClock(Clock signingClock) { + this.signingClock = Objects.requireNonNull(signingClock, "signingClock"); + return this; + } + + Builder currentTimeMillis(LongSupplier currentTimeMillis) { + this.currentTimeMillis = + Objects.requireNonNull(currentTimeMillis, "currentTimeMillis"); + return this; + } + + Builder random(DoubleSupplier random) { + this.random = Objects.requireNonNull(random, "random"); + return this; + } + + Builder scheduler(Scheduler scheduler) { + this.scheduler = Objects.requireNonNull(scheduler, "scheduler"); + return this; + } + + Builder beforeInstall(Runnable beforeInstall) { + this.beforeInstall = Objects.requireNonNull(beforeInstall, "beforeInstall"); + return this; + } + + Builder onSharedRefresh(Runnable onSharedRefresh) { + this.onSharedRefresh = + Objects.requireNonNull(onSharedRefresh, "onSharedRefresh"); + return this; + } + + /** + * Validates the configuration and creates the manager. + * + * @return configured caching token manager + */ + public ElastiCacheIamAuthTokenManager build() { + return new ElastiCacheIamAuthTokenManager(this); + } + } + + interface Cancellable { + void cancel(); + } + + interface Scheduler { + Cancellable schedule(Runnable task, long delayMillis); + + void execute(Runnable task); + + void close(); + } + + private static final class DefaultScheduler implements Scheduler { + private final ScheduledExecutorService executor = + Executors.newSingleThreadScheduledExecutor(new DaemonThreadFactory()); + + @Override + public Cancellable schedule(Runnable task, long delayMillis) { + ScheduledFuture future = + executor.schedule(task, delayMillis, TimeUnit.MILLISECONDS); + return () -> future.cancel(false); + } + + @Override + public void execute(Runnable task) { + executor.execute(task); + } + + @Override + public void close() { + executor.shutdownNow(); + } + } + + private static final class DaemonThreadFactory implements ThreadFactory { + @Override + public Thread newThread(Runnable runnable) { + Thread thread = new Thread(runnable, "elasticache-iam-token-refresh"); + thread.setDaemon(true); + return thread; + } + } + + private static final class CachedToken { + private final String token; + private final long refreshAt; + private final long expiresAt; + + private CachedToken(String token, long refreshAt, long expiresAt) { + this.token = token; + this.refreshAt = refreshAt; + this.expiresAt = expiresAt; + } + } +} diff --git a/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java new file mode 100644 index 0000000..d2f1d1b --- /dev/null +++ b/java/src/main/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProvider.java @@ -0,0 +1,316 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.elasticache.auth; + +import java.net.URI; +import java.time.Clock; +import java.time.Duration; +import java.util.Locale; +import java.util.Objects; +import java.util.function.Supplier; +import java.util.regex.Pattern; + +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.http.SdkHttpFullRequest; +import software.amazon.awssdk.http.SdkHttpMethod; +import software.amazon.awssdk.http.auth.aws.signer.AwsV4HttpSigner; +import software.amazon.awssdk.http.auth.spi.signer.HttpSigner; +import software.amazon.awssdk.http.auth.spi.signer.SignedRequest; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.regions.providers.AwsRegionProvider; +import software.amazon.awssdk.regions.providers.DefaultAwsRegionProviderChain; + +/** + * Generates Amazon ElastiCache IAM authentication tokens. + * + *

The provider is client-agnostic. Call {@link #getToken()} whenever a Redis or + * Valkey client opens a connection, and use {@link #getUserId()} as the ACL username. + * A new token is signed on every call so rotating AWS credentials are picked up. + * + *

Exactly one of {@link Builder#serverlessCacheName(String)} and + * {@link Builder#replicationGroupId(String)} is required. + */ +public final class ElastiCacheIamAuthTokenProvider { + private static final String URL_SCHEME_PREFIX = "https://"; + private static final String SERVICE_NAME = "elasticache"; + private static final String AWS_REGION_ENV_VAR = "AWS_REGION"; + static final Duration TOKEN_TTL = Duration.ofSeconds(900); + private static final Pattern CACHE_NAME_PATTERN = + Pattern.compile("^[a-zA-Z][a-zA-Z0-9]*(-[a-zA-Z0-9]+)*$"); + private static final Pattern USER_ID_PATTERN = + Pattern.compile("^(?:default\\.)?[a-zA-Z][a-zA-Z0-9\\-]*$"); + private static final String NO_REGION_MESSAGE = + "No AWS region found. Pass region explicitly, or configure one via " + + "AWS_REGION or the standard AWS SDK region provider chain."; + private static final String NO_CREDENTIALS_MESSAGE = + "No AWS credentials found. Configure credentials via the environment, " + + "shared config/credentials files, or an instance/container role."; + + private final String cacheName; + private final String userId; + private final Region region; + private final boolean serverless; + private final AwsCredentialsProvider credentialsProvider; + private final AwsV4HttpSigner signer; + private final Clock signingClock; + + private ElastiCacheIamAuthTokenProvider(Builder builder) { + Target target = resolveTarget(builder.serverlessCacheName, builder.replicationGroupId); + this.cacheName = target.cacheName; + this.serverless = target.serverless; + this.userId = validateAndNormalize("userId", builder.userId, USER_ID_PATTERN); + this.region = resolveRegion( + builder.region, builder.awsRegionEnvironmentProvider, builder.regionProvider); + this.credentialsProvider = builder.credentialsProvider == null + ? DefaultCredentialsProvider.builder().build() + : builder.credentialsProvider; + this.signer = AwsV4HttpSigner.create(); + this.signingClock = builder.signingClock == null ? Clock.systemUTC() : builder.signingClock; + } + + /** + * Creates a builder for an IAM authentication token provider. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Returns the normalized ElastiCache user ID to send as the ACL username. + * + * @return lowercase ElastiCache user ID + */ + public String getUserId() { + return userId; + } + + /** + * Signs and returns a fresh IAM authentication token. + * + * @return presigned URL with the URI scheme removed + * @throws ConfigurationException if credentials cannot be resolved + */ + public String getToken() { + AwsCredentials credentials; + try { + credentials = credentialsProvider.resolveCredentials(); + } catch (RuntimeException exception) { + throw new ConfigurationException(NO_CREDENTIALS_MESSAGE); + } + if (credentials == null + || !hasText(credentials.accessKeyId()) + || !hasText(credentials.secretAccessKey())) { + throw new ConfigurationException(NO_CREDENTIALS_MESSAGE); + } + + SdkHttpFullRequest.Builder request = SdkHttpFullRequest.builder() + .method(SdkHttpMethod.GET) + .uri(URI.create(URL_SCHEME_PREFIX + cacheName + "/")) + .appendRawQueryParameter("Action", "connect") + .appendRawQueryParameter("User", userId); + if (serverless) { + request.appendRawQueryParameter("ResourceType", "ServerlessCache"); + } + + SignedRequest signedRequest = signer.sign(signRequest -> signRequest + .identity(credentials) + .request(request.build()) + .putProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, SERVICE_NAME) + .putProperty(AwsV4HttpSigner.REGION_NAME, region.id()) + .putProperty( + AwsV4HttpSigner.AUTH_LOCATION, + AwsV4HttpSigner.AuthLocation.QUERY_STRING) + .putProperty(AwsV4HttpSigner.EXPIRATION_DURATION, TOKEN_TTL) + .putProperty(HttpSigner.SIGNING_CLOCK, signingClock) + .build()); + + URI signedUri = ((SdkHttpFullRequest) signedRequest.request()).getUri(); + String token = signedUri.toString(); + if (!token.startsWith(URL_SCHEME_PREFIX)) { + throw new IllegalStateException( + "ElastiCache IAM signer returned an unexpected URI scheme"); + } + return token.substring(URL_SCHEME_PREFIX.length()); + } + + private static Target resolveTarget(String serverlessCacheName, String replicationGroupId) { + boolean hasServerless = hasText(serverlessCacheName); + boolean hasReplicationGroup = hasText(replicationGroupId); + if (hasServerless == hasReplicationGroup) { + throw new InvalidParameterException( + "Invalid parameter combination for 'serverlessCacheName' and " + + "'replicationGroupId': exactly one must be provided."); + } + if (hasServerless) { + return new Target( + validateAndNormalize( + "serverlessCacheName", serverlessCacheName, CACHE_NAME_PATTERN), + true); + } + return new Target( + validateAndNormalize( + "replicationGroupId", replicationGroupId, CACHE_NAME_PATTERN), + false); + } + + private static Region resolveRegion( + Region explicitRegion, + Supplier awsRegionEnvironmentProvider, + AwsRegionProvider regionProvider) { + if (explicitRegion != null) { + return explicitRegion; + } + + Supplier environmentProvider = awsRegionEnvironmentProvider == null + ? () -> System.getenv(AWS_REGION_ENV_VAR) + : awsRegionEnvironmentProvider; + String environmentRegion = environmentProvider.get(); + if (hasText(environmentRegion)) { + return Region.of(environmentRegion); + } + + AwsRegionProvider provider = regionProvider == null + ? DefaultAwsRegionProviderChain.builder().build() + : regionProvider; + try { + Region resolved = provider.getRegion(); + if (resolved != null && hasText(resolved.id())) { + return resolved; + } + } catch (SdkClientException exception) { + throw new ConfigurationException(NO_REGION_MESSAGE); + } + throw new ConfigurationException(NO_REGION_MESSAGE); + } + + private static String validateAndNormalize( + String parameter, String value, Pattern pattern) { + String normalized = value == null ? "" : value.toLowerCase(Locale.ROOT); + if (!pattern.matcher(normalized).matches()) { + throw new InvalidParameterException( + "Invalid value for parameter '" + parameter + "': must match " + + pattern.pattern()); + } + return normalized; + } + + private static boolean hasText(String value) { + return value != null && !value.isEmpty(); + } + + private static final class Target { + private final String cacheName; + private final boolean serverless; + + private Target(String cacheName, boolean serverless) { + this.cacheName = cacheName; + this.serverless = serverless; + } + } + + /** + * Builder for {@link ElastiCacheIamAuthTokenProvider}. + */ + public static final class Builder { + private String userId; + private Region region; + private String serverlessCacheName; + private String replicationGroupId; + private AwsCredentialsProvider credentialsProvider; + private Supplier awsRegionEnvironmentProvider; + private AwsRegionProvider regionProvider; + private Clock signingClock; + + private Builder() {} + + /** + * Sets the IAM-enabled ElastiCache user ID. + * + * @param userId ElastiCache user ID + * @return this builder + */ + public Builder userId(String userId) { + this.userId = userId; + return this; + } + + /** + * Sets the signing region. When omitted, {@code AWS_REGION} is checked before the + * standard AWS SDK region provider chain. The AWS SDK for Java does not read + * {@code AWS_DEFAULT_REGION}. + * + * @param region AWS region + * @return this builder + */ + public Builder region(Region region) { + this.region = region; + return this; + } + + /** + * Sets the serverless cache name. + * + * @param serverlessCacheName serverless cache name + * @return this builder + */ + public Builder serverlessCacheName(String serverlessCacheName) { + this.serverlessCacheName = serverlessCacheName; + return this; + } + + /** + * Sets the node-based replication group ID. + * + * @param replicationGroupId replication group ID + * @return this builder + */ + public Builder replicationGroupId(String replicationGroupId) { + this.replicationGroupId = replicationGroupId; + return this; + } + + /** + * Sets a credentials provider. The standard AWS credentials provider chain is the default. + * + * @param credentialsProvider AWS credentials provider + * @return this builder + */ + public Builder credentialsProvider(AwsCredentialsProvider credentialsProvider) { + this.credentialsProvider = Objects.requireNonNull( + credentialsProvider, "credentialsProvider"); + return this; + } + + Builder regionProvider(AwsRegionProvider regionProvider) { + this.regionProvider = Objects.requireNonNull(regionProvider, "regionProvider"); + return this; + } + + Builder awsRegionEnvironmentProvider(Supplier awsRegionEnvironmentProvider) { + this.awsRegionEnvironmentProvider = Objects.requireNonNull( + awsRegionEnvironmentProvider, "awsRegionEnvironmentProvider"); + return this; + } + + Builder signingClock(Clock signingClock) { + this.signingClock = Objects.requireNonNull(signingClock, "signingClock"); + return this; + } + + /** + * Validates the configuration and creates the provider. + * + * @return configured IAM authentication token provider + */ + public ElastiCacheIamAuthTokenProvider build() { + return new ElastiCacheIamAuthTokenProvider(this); + } + } +} diff --git a/java/src/main/java/software/amazon/elasticache/auth/InvalidParameterException.java b/java/src/main/java/software/amazon/elasticache/auth/InvalidParameterException.java new file mode 100644 index 0000000..d7069fd --- /dev/null +++ b/java/src/main/java/software/amazon/elasticache/auth/InvalidParameterException.java @@ -0,0 +1,21 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.elasticache.auth; + +/** + * Indicates an invalid parameter value or parameter combination. + */ +public final class InvalidParameterException extends ToolkitInputException { + private static final long serialVersionUID = 1L; + + /** + * Creates an invalid parameter exception. + * + * @param message actionable error message + */ + public InvalidParameterException(String message) { + super(message); + } +} diff --git a/java/src/main/java/software/amazon/elasticache/auth/TokenRefreshException.java b/java/src/main/java/software/amazon/elasticache/auth/TokenRefreshException.java new file mode 100644 index 0000000..46d40d8 --- /dev/null +++ b/java/src/main/java/software/amazon/elasticache/auth/TokenRefreshException.java @@ -0,0 +1,35 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.elasticache.auth; + +/** + * Indicates that a cached token could not be refreshed before it expired, or + * that its token manager was closed. + * + *

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