diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 863a9e4..2db165e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -39,3 +39,21 @@ updates: 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: + java-examples: + patterns: ["*"] 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/.gitignore b/.gitignore index 106bde7..67969fd 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,10 @@ *.iml .vscode/ -# Language-specific ignores live in python/.gitignore and node/.gitignore so +# Python helper scripts at the repository root. +__pycache__/ +*.py[cod] + +# Language-specific ignores live in python/.gitignore, java/.gitignore, and +# node/.gitignore so # each package stays self-contained. diff --git a/README.md b/README.md index 0ef57bf..466ec61 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ This repository contains libraries and tools for working with [Amazon ElastiCache](https://aws.amazon.com/elasticache/). -The Python and Node.js libraries support +The Python, Java, and Node.js libraries offer [IAM authentication](https://docs.aws.amazon.com/AmazonElastiCache/latest/dg/auth-iam.html) for ElastiCache. They generate the signed token required to connect with an IAM-enabled user, allowing applications to authenticate to a Valkey or Redis OSS @@ -14,6 +14,7 @@ cache with AWS credentials. | Language | Package | Documentation | |---|---|---| | Python | [`developer-toolkit-elasticache`](https://pypi.org/project/developer-toolkit-elasticache/) on PyPI | [python/README.md](python/README.md) | +| Java | `software.amazon.elasticache:developer-toolkit-elasticache` | [java/README.md](java/README.md) | | Node.js | `@aws/developer-toolkit-elasticache` on npm | [node/README.md](node/README.md) | ## Quick start @@ -38,6 +39,22 @@ Use the returned token as the password when connecting to the cache. See the [Python README](python/README.md) for requirements, the credential chain, the Python command-line tool, and client integration examples. +### Java + +```java +ElastiCacheIamAuthTokenProvider auth = ElastiCacheIamAuthTokenProvider.builder() + .serverlessCacheName("my-cache") + .userId("iam-user") + .region(Region.US_EAST_1) + .build(); + +String username = auth.getUserId(); +String password = auth.getToken(); +``` + +See the [Java README](java/README.md) for Maven setup, region and credential +resolution, and a standalone TLS connection example. + ### Node.js ```bash @@ -66,7 +83,6 @@ tracking bugs and feature requests. - Ask a question or [open a discussion](https://github.com/aws/developer-toolkit-elasticache/discussions). - If you think you may have found a bug, please [open an issue](https://github.com/aws/developer-toolkit-elasticache/issues/new). - ## Contributing Contributions are welcome. Open an issue to discuss a change before sending a pull diff --git a/java/.gitignore b/java/.gitignore new file mode 100644 index 0000000..18d2ca6 --- /dev/null +++ b/java/.gitignore @@ -0,0 +1,4 @@ +target/ +.classpath +.project +.settings/ diff --git a/java/CHANGELOG.md b/java/CHANGELOG.md new file mode 100644 index 0000000..18230a4 --- /dev/null +++ b/java/CHANGELOG.md @@ -0,0 +1,27 @@ +# Changelog + +All notable changes to the Java package will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this package uses [Semantic Versioning](https://semver.org/). + +## [Unreleased] + +### Added + +- Initial Java IAM authentication token generator for serverless caches and + node-based replication groups. +- Input validation, lowercase normalization, AWS region and credential resolution, + typed user-correctable errors, and deterministic SigV4 signing tests. +- Credential providers that fail or return unusable credentials are normalized to + an actionable `ConfigurationException`. +- Maven build, Java version CI matrix, documentation, and dependency updates. + +### Notes + +- Region resolution follows the Java contract: explicit region, then `AWS_REGION`, + then the standard AWS SDK region provider chain. Unlike botocore, the AWS SDK for + Java does not read `AWS_DEFAULT_REGION`. +- Java examples demonstrate Lettuce `RedisCredentialsProvider` integration and the + raw TLS/Redis protocol exchange for custom clients and proxies. The wire-level + example verifies the endpoint hostname during the TLS handshake. diff --git a/java/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..e2321e4 --- /dev/null +++ b/java/README.md @@ -0,0 +1,118 @@ +# 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. + +## 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 + 0.1.0-SNAPSHOT + +``` + +## 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. + +## Reconnecting clients + +Clients should request fresh credentials whenever they connect or reconnect. The +Lettuce example implements `RedisCredentialsProvider` so each credentials request +calls `getToken()`: + +```java +RedisCredentialsProvider credentialsProvider = new RedisCredentialsProvider() { + @Override + public Mono resolveCredentials() { + return Mono.fromSupplier( + () -> RedisCredentials.just(auth.getUserId(), auth.getToken())); + } +}; +``` + +Run the complete +[`ConnectWithLettuce`](examples/src/main/java/software/amazon/elasticache/examples/ConnectWithLettuce.java) +example after installing the toolkit: + +```bash +mvn --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..05c6ae2 --- /dev/null +++ b/java/examples/pom.xml @@ -0,0 +1,51 @@ + + + + 4.0.0 + + software.amazon.elasticache + developer-toolkit-elasticache-examples + 0.1.0-SNAPSHOT + + + UTF-8 + 8 + 7.7.0.RELEASE + + + + + software.amazon.elasticache + developer-toolkit-elasticache + 0.1.0-SNAPSHOT + + + io.lettuce + lettuce-core + ${lettuce.version} + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.14.1 + + ${maven.compiler.release} + + + + org.codehaus.mojo + exec-maven-plugin + 3.5.0 + + + + diff --git a/java/examples/src/main/java/software/amazon/elasticache/examples/ConnectServerlessCache.java b/java/examples/src/main/java/software/amazon/elasticache/examples/ConnectServerlessCache.java new file mode 100644 index 0000000..207a528 --- /dev/null +++ b/java/examples/src/main/java/software/amazon/elasticache/examples/ConnectServerlessCache.java @@ -0,0 +1,127 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.elasticache.examples; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; + +import software.amazon.awssdk.regions.Region; +import software.amazon.elasticache.auth.ElastiCacheIamAuthTokenProvider; + +/** + * Connects to an ElastiCache serverless cache over TLS using IAM authentication. + * + *

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

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

Exactly one of {@link Builder#serverlessCacheName(String)} and + * {@link Builder#replicationGroupId(String)} is required. + */ +public final class ElastiCacheIamAuthTokenProvider { + private static final String URL_SCHEME_PREFIX = "https://"; + private static final String SERVICE_NAME = "elasticache"; + private static final String AWS_REGION_ENV_VAR = "AWS_REGION"; + private static final Duration TOKEN_TTL = Duration.ofSeconds(900); + private static final Pattern CACHE_NAME_PATTERN = + Pattern.compile("^[a-zA-Z][a-zA-Z0-9]*(-[a-zA-Z0-9]+)*$"); + private static final Pattern USER_ID_PATTERN = + Pattern.compile("^(?:default\\.)?[a-zA-Z][a-zA-Z0-9\\-]*$"); + private static final String NO_REGION_MESSAGE = + "No AWS region found. Pass region explicitly, or configure one via " + + "AWS_REGION or the standard AWS SDK region provider chain."; + private static final String NO_CREDENTIALS_MESSAGE = + "No AWS credentials found. Configure credentials via the environment, " + + "shared config/credentials files, or an instance/container role."; + + private final String cacheName; + private final String userId; + private final Region region; + private final boolean serverless; + private final AwsCredentialsProvider credentialsProvider; + private final AwsV4HttpSigner signer; + private final Clock signingClock; + + private ElastiCacheIamAuthTokenProvider(Builder builder) { + Target target = resolveTarget(builder.serverlessCacheName, builder.replicationGroupId); + this.cacheName = target.cacheName; + this.serverless = target.serverless; + this.userId = validateAndNormalize("userId", builder.userId, USER_ID_PATTERN); + this.region = resolveRegion( + builder.region, builder.awsRegionEnvironmentProvider, builder.regionProvider); + this.credentialsProvider = builder.credentialsProvider == null + ? DefaultCredentialsProvider.builder().build() + : builder.credentialsProvider; + this.signer = AwsV4HttpSigner.create(); + this.signingClock = builder.signingClock == null ? Clock.systemUTC() : builder.signingClock; + } + + /** + * Creates a builder for an IAM authentication token provider. + * + * @return a new builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Returns the normalized ElastiCache user ID to send as the ACL username. + * + * @return lowercase ElastiCache user ID + */ + public String getUserId() { + return userId; + } + + /** + * Signs and returns a fresh IAM authentication token. + * + * @return presigned URL with the URI scheme removed + * @throws ConfigurationException if credentials cannot be resolved + */ + public String getToken() { + AwsCredentials credentials; + try { + credentials = credentialsProvider.resolveCredentials(); + } catch (RuntimeException exception) { + throw new ConfigurationException(NO_CREDENTIALS_MESSAGE); + } + if (credentials == null + || !hasText(credentials.accessKeyId()) + || !hasText(credentials.secretAccessKey())) { + throw new ConfigurationException(NO_CREDENTIALS_MESSAGE); + } + + SdkHttpFullRequest.Builder request = SdkHttpFullRequest.builder() + .method(SdkHttpMethod.GET) + .uri(URI.create(URL_SCHEME_PREFIX + cacheName + "/")) + .appendRawQueryParameter("Action", "connect") + .appendRawQueryParameter("User", userId); + if (serverless) { + request.appendRawQueryParameter("ResourceType", "ServerlessCache"); + } + + SignedRequest signedRequest = signer.sign(signRequest -> signRequest + .identity(credentials) + .request(request.build()) + .putProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, SERVICE_NAME) + .putProperty(AwsV4HttpSigner.REGION_NAME, region.id()) + .putProperty( + AwsV4HttpSigner.AUTH_LOCATION, + AwsV4HttpSigner.AuthLocation.QUERY_STRING) + .putProperty(AwsV4HttpSigner.EXPIRATION_DURATION, TOKEN_TTL) + .putProperty(HttpSigner.SIGNING_CLOCK, signingClock) + .build()); + + URI signedUri = ((SdkHttpFullRequest) signedRequest.request()).getUri(); + String token = signedUri.toString(); + if (!token.startsWith(URL_SCHEME_PREFIX)) { + throw new IllegalStateException( + "ElastiCache IAM signer returned an unexpected URI scheme"); + } + return token.substring(URL_SCHEME_PREFIX.length()); + } + + private static Target resolveTarget(String serverlessCacheName, String replicationGroupId) { + boolean hasServerless = hasText(serverlessCacheName); + boolean hasReplicationGroup = hasText(replicationGroupId); + if (hasServerless == hasReplicationGroup) { + throw new InvalidParameterException( + "Invalid parameter combination for 'serverlessCacheName' and " + + "'replicationGroupId': exactly one must be provided."); + } + if (hasServerless) { + return new Target( + validateAndNormalize( + "serverlessCacheName", serverlessCacheName, CACHE_NAME_PATTERN), + true); + } + return new Target( + validateAndNormalize( + "replicationGroupId", replicationGroupId, CACHE_NAME_PATTERN), + false); + } + + private static Region resolveRegion( + Region explicitRegion, + Supplier awsRegionEnvironmentProvider, + AwsRegionProvider regionProvider) { + if (explicitRegion != null) { + return explicitRegion; + } + + Supplier environmentProvider = awsRegionEnvironmentProvider == null + ? () -> System.getenv(AWS_REGION_ENV_VAR) + : awsRegionEnvironmentProvider; + String environmentRegion = environmentProvider.get(); + if (hasText(environmentRegion)) { + return Region.of(environmentRegion); + } + + AwsRegionProvider provider = regionProvider == null + ? DefaultAwsRegionProviderChain.builder().build() + : regionProvider; + try { + Region resolved = provider.getRegion(); + if (resolved != null && hasText(resolved.id())) { + return resolved; + } + } catch (SdkClientException exception) { + throw new ConfigurationException(NO_REGION_MESSAGE); + } + throw new ConfigurationException(NO_REGION_MESSAGE); + } + + private static String validateAndNormalize( + String parameter, String value, Pattern pattern) { + String normalized = value == null ? "" : value.toLowerCase(Locale.ROOT); + if (!pattern.matcher(normalized).matches()) { + throw new InvalidParameterException( + "Invalid value for parameter '" + parameter + "': must match " + + pattern.pattern()); + } + return normalized; + } + + private static boolean hasText(String value) { + return value != null && !value.isEmpty(); + } + + private static final class Target { + private final String cacheName; + private final boolean serverless; + + private Target(String cacheName, boolean serverless) { + this.cacheName = cacheName; + this.serverless = serverless; + } + } + + /** + * Builder for {@link ElastiCacheIamAuthTokenProvider}. + */ + public static final class Builder { + private String userId; + private Region region; + private String serverlessCacheName; + private String replicationGroupId; + private AwsCredentialsProvider credentialsProvider; + private Supplier awsRegionEnvironmentProvider; + private AwsRegionProvider regionProvider; + private Clock signingClock; + + private Builder() {} + + /** + * Sets the IAM-enabled ElastiCache user ID. + * + * @param userId ElastiCache user ID + * @return this builder + */ + public Builder userId(String userId) { + this.userId = userId; + return this; + } + + /** + * Sets the signing region. When omitted, {@code AWS_REGION} is checked before the + * standard AWS SDK region provider chain. The AWS SDK for Java does not read + * {@code AWS_DEFAULT_REGION}. + * + * @param region AWS region + * @return this builder + */ + public Builder region(Region region) { + this.region = region; + return this; + } + + /** + * Sets the serverless cache name. + * + * @param serverlessCacheName serverless cache name + * @return this builder + */ + public Builder serverlessCacheName(String serverlessCacheName) { + this.serverlessCacheName = serverlessCacheName; + return this; + } + + /** + * Sets the node-based replication group ID. + * + * @param replicationGroupId replication group ID + * @return this builder + */ + public Builder replicationGroupId(String replicationGroupId) { + this.replicationGroupId = replicationGroupId; + return this; + } + + /** + * Sets a credentials provider. The standard AWS credentials provider chain is the default. + * + * @param credentialsProvider AWS credentials provider + * @return this builder + */ + public Builder credentialsProvider(AwsCredentialsProvider credentialsProvider) { + this.credentialsProvider = Objects.requireNonNull( + credentialsProvider, "credentialsProvider"); + return this; + } + + Builder regionProvider(AwsRegionProvider regionProvider) { + this.regionProvider = Objects.requireNonNull(regionProvider, "regionProvider"); + return this; + } + + Builder awsRegionEnvironmentProvider(Supplier awsRegionEnvironmentProvider) { + this.awsRegionEnvironmentProvider = Objects.requireNonNull( + awsRegionEnvironmentProvider, "awsRegionEnvironmentProvider"); + return this; + } + + Builder signingClock(Clock signingClock) { + this.signingClock = Objects.requireNonNull(signingClock, "signingClock"); + return this; + } + + /** + * Validates the configuration and creates the provider. + * + * @return configured IAM authentication token provider + */ + public ElastiCacheIamAuthTokenProvider build() { + return new ElastiCacheIamAuthTokenProvider(this); + } + } +} diff --git a/java/src/main/java/software/amazon/elasticache/auth/InvalidParameterException.java b/java/src/main/java/software/amazon/elasticache/auth/InvalidParameterException.java new file mode 100644 index 0000000..d7069fd --- /dev/null +++ b/java/src/main/java/software/amazon/elasticache/auth/InvalidParameterException.java @@ -0,0 +1,21 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.elasticache.auth; + +/** + * Indicates an invalid parameter value or parameter combination. + */ +public final class InvalidParameterException extends ToolkitInputException { + private static final long serialVersionUID = 1L; + + /** + * Creates an invalid parameter exception. + * + * @param message actionable error message + */ + public InvalidParameterException(String message) { + super(message); + } +} diff --git a/java/src/main/java/software/amazon/elasticache/auth/ToolkitInputException.java b/java/src/main/java/software/amazon/elasticache/auth/ToolkitInputException.java new file mode 100644 index 0000000..a34d821 --- /dev/null +++ b/java/src/main/java/software/amazon/elasticache/auth/ToolkitInputException.java @@ -0,0 +1,21 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.elasticache.auth; + +/** + * Base class for IAM token generation failures that callers can correct. + */ +public class ToolkitInputException extends RuntimeException { + private static final long serialVersionUID = 1L; + + /** + * Creates a correctable toolkit exception. + * + * @param message actionable error message + */ + public ToolkitInputException(String message) { + super(message); + } +} diff --git a/java/src/main/java/software/amazon/elasticache/auth/package-info.java b/java/src/main/java/software/amazon/elasticache/auth/package-info.java new file mode 100644 index 0000000..25886ab --- /dev/null +++ b/java/src/main/java/software/amazon/elasticache/auth/package-info.java @@ -0,0 +1,9 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * IAM authentication token generation for Amazon ElastiCache. + */ +package software.amazon.elasticache.auth; diff --git a/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java new file mode 100644 index 0000000..bf18229 --- /dev/null +++ b/java/src/test/java/software/amazon/elasticache/auth/ElastiCacheIamAuthTokenProviderTest.java @@ -0,0 +1,490 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.elasticache.auth; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.regions.Region; + +class ElastiCacheIamAuthTokenProviderTest { + private static final String CACHE = "my-cache"; + private static final String USER = "testuser"; + private static final Region REGION = Region.US_EAST_1; + private static final AwsCredentials CREDENTIALS = AwsSessionCredentials.create( + "FRAIDA1FODNN7EXAMPLE", "secret", "token"); + private static final Clock KNOWN_ANSWER_CLOCK = + Clock.fixed(Instant.parse("2025-01-01T00:00:00Z"), ZoneOffset.UTC); + private static final AwsCredentials KNOWN_ANSWER_CREDENTIALS = AwsBasicCredentials.create( + "AKIAIOSFODNN7EXAMPLE", "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"); + + @Test + void generatesServerlessToken() { + String token = providerBuilder().serverlessCacheName(CACHE).build().getToken(); + + assertValidToken(token); + assertTrue(token.contains("ResourceType=ServerlessCache")); + } + + @Test + void generatesReplicationGroupToken() { + String token = providerBuilder().replicationGroupId(CACHE).build().getToken(); + + assertValidToken(token); + assertFalse(token.contains("ResourceType")); + } + + @Test + void exposesNormalizedUserIdAndSignsTheSameValue() { + ElastiCacheIamAuthTokenProvider provider = providerBuilder() + .serverlessCacheName("My-Cache") + .userId("MixedCaseUser") + .build(); + + assertEquals("mixedcaseuser", provider.getUserId()); + String token = provider.getToken(); + assertTrue(token.startsWith("my-cache/")); + assertTrue(token.contains("User=mixedcaseuser")); + assertFalse(token.contains("MixedCaseUser")); + } + + @Test + void resolvesRegionAtConstruction() { + ElastiCacheIamAuthTokenProvider provider = providerBuilder() + .serverlessCacheName(CACHE) + .region(null) + .awsRegionEnvironmentProvider(() -> null) + .regionProvider(() -> Region.EU_WEST_1) + .build(); + + assertTrue(provider.getToken().contains("%2Feu-west-1%2F")); + } + + @Test + void explicitRegionWinsOverRegionProvider() { + ElastiCacheIamAuthTokenProvider provider = providerBuilder() + .serverlessCacheName(CACHE) + .awsRegionEnvironmentProvider(() -> "ap-southeast-2") + .regionProvider(() -> Region.EU_WEST_1) + .build(); + + String token = provider.getToken(); + assertTrue(token.contains("%2Fus-east-1%2F")); + assertFalse(token.contains("eu-west-1")); + } + + @Test + void awsRegionEnvironmentWinsOverSdkRegionChain() { + ElastiCacheIamAuthTokenProvider provider = providerBuilder() + .serverlessCacheName(CACHE) + .region(null) + .awsRegionEnvironmentProvider(() -> "ap-southeast-2") + .regionProvider(() -> Region.EU_WEST_1) + .build(); + + String token = provider.getToken(); + assertTrue(token.contains("%2Fap-southeast-2%2F")); + assertFalse(token.contains("eu-west-1")); + } + + @Test + void sdkRegionChainIsUsedWhenAwsRegionIsAbsent() { + ElastiCacheIamAuthTokenProvider provider = providerBuilder() + .serverlessCacheName(CACHE) + .region(null) + .awsRegionEnvironmentProvider(() -> null) + .regionProvider(() -> Region.EU_WEST_1) + .build(); + + String token = provider.getToken(); + assertTrue(token.contains("%2Feu-west-1%2F")); + } + + @Test + void missingRegionFailsAtConstruction() { + String providerDetail = "secret region detail\nmust not escape"; + ConfigurationException exception = assertThrows( + ConfigurationException.class, + () -> providerBuilder() + .serverlessCacheName(CACHE) + .region(null) + .awsRegionEnvironmentProvider(() -> null) + .regionProvider(() -> { + throw SdkClientException.create(providerDetail); + }) + .build()); + + assertTrue(exception.getMessage().contains("AWS_REGION")); + assertTrue(exception.getMessage().contains("AWS SDK")); + assertFalse(renderStackTrace(exception).contains(providerDetail)); + } + + @Test + void nullRegionResultFailsAtConstruction() { + ConfigurationException exception = assertThrows( + ConfigurationException.class, + () -> providerBuilder() + .serverlessCacheName(CACHE) + .region(null) + .awsRegionEnvironmentProvider(() -> null) + .regionProvider(() -> null) + .build()); + + assertTrue(exception.getMessage().contains("AWS_REGION")); + assertTrue(exception.getMessage().contains("AWS SDK")); + } + + @Test + void validatesParametersBeforeResolvingRegion() { + AtomicInteger regionCalls = new AtomicInteger(); + + assertThrows( + InvalidParameterException.class, + () -> providerBuilder() + .serverlessCacheName("invalid_name") + .region(null) + .awsRegionEnvironmentProvider(() -> null) + .regionProvider(() -> { + regionCalls.incrementAndGet(); + return REGION; + }) + .build()); + + assertEquals(0, regionCalls.get()); + } + + @Test + void readsCredentialsForEveryToken() { + AtomicInteger calls = new AtomicInteger(); + AwsCredentialsProvider credentialsProvider = () -> { + calls.incrementAndGet(); + return CREDENTIALS; + }; + ElastiCacheIamAuthTokenProvider provider = providerBuilder() + .serverlessCacheName(CACHE) + .credentialsProvider(credentialsProvider) + .build(); + + provider.getToken(); + provider.getToken(); + + assertEquals(2, calls.get()); + } + + @Test + void rotatedCredentialsChangeTheToken() { + AtomicInteger calls = new AtomicInteger(); + AwsCredentialsProvider credentialsProvider = () -> calls.getAndIncrement() == 0 + ? AwsBasicCredentials.create("AKIAIOSFODNN7EXAMPLE", "first-secret") + : AwsBasicCredentials.create("AKIAI44QH8DHBEXAMPLE", "second-secret"); + ElastiCacheIamAuthTokenProvider provider = providerBuilder() + .serverlessCacheName(CACHE) + .credentialsProvider(credentialsProvider) + .build(); + + assertNotEquals(provider.getToken(), provider.getToken()); + } + + @Test + void retainsConstructionRegionWhileCredentialsRotate() { + AtomicInteger regionCalls = new AtomicInteger(); + AtomicInteger credentialCalls = new AtomicInteger(); + AtomicReference regionSource = new AtomicReference<>(REGION); + AwsCredentialsProvider credentialsProvider = () -> + credentialCalls.getAndIncrement() == 0 + ? AwsBasicCredentials.create( + "AKIAIOSFODNN7EXAMPLE", "first-secret") + : AwsBasicCredentials.create( + "AKIAI44QH8DHBEXAMPLE", "second-secret"); + + ElastiCacheIamAuthTokenProvider provider = providerBuilder() + .serverlessCacheName(CACHE) + .region(null) + .awsRegionEnvironmentProvider(() -> null) + .regionProvider(() -> { + regionCalls.incrementAndGet(); + return regionSource.get(); + }) + .credentialsProvider(credentialsProvider) + .build(); + + regionSource.set(Region.EU_WEST_1); + String first = provider.getToken(); + regionSource.set(Region.AP_SOUTHEAST_1); + String second = provider.getToken(); + + assertEquals(1, regionCalls.get()); + assertEquals(2, credentialCalls.get()); + assertTrue(first.contains("%2Fus-east-1%2F")); + assertTrue(second.contains("%2Fus-east-1%2F")); + assertFalse(first.contains("%2Feu-west-1%2F")); + assertFalse(second.contains("%2Fap-southeast-1%2F")); + assertNotEquals(first, second); + } + + @Test + void missingCredentialsHasActionableError() { + String providerDetail = "secret credential detail\nmust not escape"; + ElastiCacheIamAuthTokenProvider provider = providerBuilder() + .serverlessCacheName(CACHE) + .credentialsProvider(() -> { + throw new IllegalStateException(providerDetail); + }) + .build(); + + ConfigurationException exception = + assertThrows(ConfigurationException.class, provider::getToken); + assertEquals( + "No AWS credentials found. Configure credentials via the environment, " + + "shared config/credentials files, or an instance/container role.", + exception.getMessage()); + assertFalse(renderStackTrace(exception).contains(providerDetail)); + } + + @Test + void unusableCredentialsHaveActionableError() { + AwsCredentials unusableCredentials = new AwsCredentials() { + @Override + public String accessKeyId() { + return ""; + } + + @Override + public String secretAccessKey() { + return "not-a-usable-key"; + } + }; + ElastiCacheIamAuthTokenProvider provider = providerBuilder() + .serverlessCacheName(CACHE) + .credentialsProvider(() -> unusableCredentials) + .build(); + + assertThrows(ConfigurationException.class, provider::getToken); + } + + @Test + void requiresExactlyOneTarget() { + assertThrows(InvalidParameterException.class, () -> providerBuilder().build()); + assertThrows( + InvalidParameterException.class, + () -> providerBuilder() + .serverlessCacheName(CACHE) + .replicationGroupId(CACHE) + .build()); + } + + @Test + void rejectsInvalidCacheNamesWithoutEchoingThem() { + List badNames = Arrays.asList( + "evil.com/path", + "my-cache@evil.com", + "my-cache/?X=1", + "my cache", + "my_cache", + "1-cache", + "-cache", + "cache-", + "my--cache", + "-"); + + for (String badName : badNames) { + InvalidParameterException exception = assertThrows( + InvalidParameterException.class, + () -> providerBuilder().serverlessCacheName(badName).build()); + assertTrue(exception.getMessage().contains("serverlessCacheName")); + } + } + + @Test + void invalidReplicationGroupIdNamesThatParameter() { + InvalidParameterException exception = assertThrows( + InvalidParameterException.class, + () -> providerBuilder().replicationGroupId("bad_name!").build()); + + assertTrue(exception.getMessage().contains("replicationGroupId")); + } + + @Test + void emptyCacheNameIsRejectedAsMissingTarget() { + assertThrows( + InvalidParameterException.class, + () -> providerBuilder().serverlessCacheName("").build()); + } + + @Test + void parameterErrorsDoNotEchoHostileValues() { + String hostile = "evil.com/inject\nlog-entry"; + InvalidParameterException exception = assertThrows( + InvalidParameterException.class, + () -> providerBuilder().serverlessCacheName(hostile).build()); + + assertTrue(exception.getMessage().contains("serverlessCacheName")); + assertFalse(exception.getMessage().contains(hostile)); + } + + @Test + void acceptsValidCacheNames() { + for (String name : Arrays.asList("c", "my-cache", "cache1", "a-1-b-2", "MyCache")) { + String token = providerBuilder().serverlessCacheName(name).build().getToken(); + assertTrue(token.startsWith(name.toLowerCase() + "/")); + } + } + + @Test + void validatesUserIds() { + for (String userId : Arrays.asList( + "", "1user", "-user", "user name", "user@host", "user.name", + "default.", "default.1x", "notdefault.foo")) { + InvalidParameterException exception = assertThrows( + InvalidParameterException.class, + () -> providerBuilder() + .serverlessCacheName(CACHE) + .userId(userId) + .build()); + assertTrue(exception.getMessage().contains("userId")); + } + + for (String userId : Arrays.asList( + "iam-user", "myuser", "default", "default.iam-user", + "default.other", "DEFAULT.IAM-USER", "a-1-b-2")) { + ElastiCacheIamAuthTokenProvider provider = providerBuilder() + .serverlessCacheName(CACHE) + .userId(userId) + .build(); + assertEquals(userId.toLowerCase(), provider.getUserId()); + } + } + + @Test + void signsServiceManagedDefaultUser() { + ElastiCacheIamAuthTokenProvider provider = providerBuilder() + .serverlessCacheName(CACHE) + .userId("DEFAULT.IAM-USER") + .build(); + + assertEquals("default.iam-user", provider.getUserId()); + assertTrue(provider.getToken().contains("User=default.iam-user")); + } + + @Test + void configurationExceptionIsNotAParameterException() { + assertFalse(InvalidParameterException.class.isAssignableFrom(ConfigurationException.class)); + } + + @Test + void serverlessTokenMatchesKnownAnswer() { + String token = knownAnswerProviderBuilder() + .serverlessCacheName(CACHE) + .build() + .getToken(); + + assertKnownAnswerToken( + token, + true, + "28f349fd92f74e0192de149a74743ae7d087bb136523c342bdd67632a8360023"); + } + + @Test + void replicationGroupTokenMatchesKnownAnswer() { + String token = knownAnswerProviderBuilder() + .replicationGroupId(CACHE) + .build() + .getToken(); + + assertKnownAnswerToken( + token, + false, + "cd78a7de74c1bb1c8c685cac428f783b5433ce5fbf569af867570311d6a85797"); + } + + private static ElastiCacheIamAuthTokenProvider.Builder providerBuilder() { + return ElastiCacheIamAuthTokenProvider.builder() + .userId(USER) + .region(REGION) + .credentialsProvider(StaticCredentialsProvider.create(CREDENTIALS)); + } + + private static ElastiCacheIamAuthTokenProvider.Builder knownAnswerProviderBuilder() { + return ElastiCacheIamAuthTokenProvider.builder() + .userId(USER) + .region(REGION) + .credentialsProvider(StaticCredentialsProvider.create(KNOWN_ANSWER_CREDENTIALS)) + .signingClock(KNOWN_ANSWER_CLOCK); + } + + private static void assertValidToken(String token) { + assertTrue(token.startsWith(CACHE + "/")); + assertTrue(token.contains("Action=connect")); + assertTrue(token.contains("User=" + USER)); + assertTrue(token.contains("X-Amz-Algorithm=AWS4-HMAC-SHA256")); + assertTrue(token.contains("X-Amz-Credential")); + assertTrue(token.contains("X-Amz-Security-Token=token")); + assertTrue(token.contains("X-Amz-Signature")); + assertTrue(token.contains("X-Amz-Expires=900")); + assertFalse(token.startsWith("https://")); + } + + private static String renderStackTrace(Throwable throwable) { + StringWriter output = new StringWriter(); + throwable.printStackTrace(new PrintWriter(output)); + return output.toString(); + } + + private static void assertKnownAnswerToken( + String token, boolean serverless, String expectedSignature) { + assertTrue(token.startsWith(CACHE + "/?")); + Map parameters = rawQueryParameters(token); + assertEquals("connect", parameters.get("Action")); + assertEquals(USER, parameters.get("User")); + assertEquals("AWS4-HMAC-SHA256", parameters.get("X-Amz-Algorithm")); + assertEquals( + "AKIAIOSFODNN7EXAMPLE%2F20250101%2Fus-east-1" + + "%2Felasticache%2Faws4_request", + parameters.get("X-Amz-Credential")); + assertEquals("20250101T000000Z", parameters.get("X-Amz-Date")); + assertEquals("900", parameters.get("X-Amz-Expires")); + assertEquals("host", parameters.get("X-Amz-SignedHeaders")); + assertEquals(expectedSignature, parameters.get("X-Amz-Signature")); + if (serverless) { + assertEquals("ServerlessCache", parameters.get("ResourceType")); + assertEquals(9, parameters.size()); + } else { + assertFalse(parameters.containsKey("ResourceType")); + assertEquals(8, parameters.size()); + } + } + + private static Map rawQueryParameters(String token) { + Map parameters = new LinkedHashMap<>(); + String query = token.substring(token.indexOf('?') + 1); + for (String pair : query.split("&")) { + String[] parts = pair.split("=", 2); + parameters.put(parts[0], parts.length == 2 ? parts[1] : ""); + } + return parameters; + } +} diff --git a/python/README.md b/python/README.md index 31211f1..5f5c67d 100644 --- a/python/README.md +++ b/python/README.md @@ -69,6 +69,10 @@ The chain is checked in this order: 3. Container credentials (Amazon ECS / EKS). 4. EC2 instance profile credentials (IMDS). +Missing credentials are reported when a token is requested. Expired or otherwise +invalid credentials cannot be detected during local signing: token generation can +succeed, but ElastiCache will reject the token when the client connects. + ### Reconnecting clients Clients such as redis-py and valkey-py request credentials on every reconnection. diff --git a/scripts/java_dev.py b/scripts/java_dev.py new file mode 100644 index 0000000..1162329 --- /dev/null +++ b/scripts/java_dev.py @@ -0,0 +1,45 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0 + +import argparse +import json +import xml.etree.ElementTree as ET +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +POM = ROOT / "java" / "pom.xml" +MAVEN_NAMESPACE = {"m": "http://maven.apache.org/POM/4.0.0"} + + +def list_supported_versions() -> None: + root = ET.parse(POM).getroot() + value = root.findtext( + "m:properties/m:java.supported.versions", namespaces=MAVEN_NAMESPACE + ) + if value is None: + raise SystemExit("java.supported.versions is missing from java/pom.xml") + + versions = json.loads(value) + if ( + not isinstance(versions, list) + or not versions + or not all(isinstance(version, str) and version for version in versions) + ): + raise SystemExit( + "java.supported.versions must be a non-empty JSON string array" + ) + result = {"all": versions, "min": versions[0], "max": versions[-1]} + print(json.dumps(result, separators=(",", ":"))) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("command", choices=["list-supported-versions"]) + args = parser.parse_args() + + if args.command == "list-supported-versions": + list_supported_versions() + + +if __name__ == "__main__": + main()