diff --git a/auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/CachingIdentityResolver.java b/auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/CachingIdentityResolver.java new file mode 100644 index 000000000..f16416305 --- /dev/null +++ b/auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/CachingIdentityResolver.java @@ -0,0 +1,361 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.auth.api.identity; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import software.amazon.smithy.java.context.Context; + +/** * An {@link IdentityResolver} that caches the result of a delegate resolver and refreshes it asynchronously in the + * background before expiration. + * + *

Behavior: + *

+ * + *

This class is thread-safe. At most one refresh runs at a time (enforced by an {@link AtomicBoolean}). + * Callers never block except on cold start. + * + * @param the identity type. + */ +public final class CachingIdentityResolver implements IdentityResolver, AutoCloseable { + + private static final System.Logger LOGGER = System.getLogger(CachingIdentityResolver.class.getName()); + + private final IdentityResolver delegate; + private final Duration prefetchBuffer; + private final boolean allowExpiredCredentials; + private final Duration staleRefreshDelay; + private final Clock clock; + private final ScheduledExecutorService executor; + private final boolean ownsExecutor; + private final AtomicBoolean refreshing = new AtomicBoolean(false); + private volatile CountDownLatch coldStartLatch = new CountDownLatch(1); + + private volatile CachedValue cached; + private volatile ScheduledFuture scheduledRefresh; + + private CachingIdentityResolver(Builder builder) { + this.delegate = Objects.requireNonNull(builder.delegate, "delegate"); + this.prefetchBuffer = builder.prefetchBuffer; + this.allowExpiredCredentials = builder.allowExpiredCredentials; + this.staleRefreshDelay = builder.staleRefreshDelay; + this.clock = builder.clock; + + if (builder.executor != null) { + this.executor = builder.executor; + this.ownsExecutor = false; + } else { + this.executor = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "smithy-identity-cache-refresh"); + t.setDaemon(true); + return t; + }); + this.ownsExecutor = true; + } + } + + /** + * Create a builder. + * + * @param delegate the underlying resolver to cache. + * @param identity type. + * @return a new builder. + */ + public static Builder builder(IdentityResolver delegate) { + return new Builder<>(delegate); + } + + @Override + public IdentityResult resolveIdentity(Context requestProperties) { + CachedValue current = cached; + + // Cold start: first caller triggers refresh, others wait. + if (current == null) { + return coldStart(requestProperties); + } + + // Cache is fresh — return immediately. + if (!isInPrefetchWindow(current) && !isExpired(current)) { + return current.result; + } + + // Cache is in prefetch window or expired. Kick off async refresh if not already running. + triggerAsyncRefresh(requestProperties); + + // If expired and strict mode, we can't return stale — block for the refresh. + if (isExpired(current) && !allowExpiredCredentials) { + return blockForRefresh(current, requestProperties); + } + + return current.result; + } + + @Override + public Class identityType() { + return delegate.identityType(); + } + + @Override + public void invalidate() { + cached = null; + coldStartLatch = new CountDownLatch(1); + cancelScheduledRefresh(); + } + + @Override + public void close() { + cancelScheduledRefresh(); + if (ownsExecutor) { + executor.shutdownNow(); + } + } + + private IdentityResult coldStart(Context requestProperties) { + if (refreshing.compareAndSet(false, true)) { + try { + return doRefresh(requestProperties); + } finally { + refreshing.set(false); + coldStartLatch.countDown(); + } + } + + // Another thread is doing the cold start — wait for it. + try { + coldStartLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return IdentityResult.ofError(getClass(), "Interrupted waiting for initial credential resolution"); + } + + CachedValue result = cached; + return result != null ? result.result : IdentityResult.ofError(getClass(), "Failed to resolve credentials"); + } + + private void triggerAsyncRefresh(Context requestProperties) { + if (refreshing.compareAndSet(false, true)) { + executor.submit(() -> { + try { + doRefresh(requestProperties); + } finally { + refreshing.set(false); + } + }); + } + } + + private IdentityResult blockForRefresh(CachedValue current, Context requestProperties) { + // Strict mode: cache is expired. Try one synchronous refresh. + if (refreshing.compareAndSet(false, true)) { + try { + IdentityResult result = doRefresh(requestProperties); + // If doRefresh returned the stale cached value (shouldn't in strict mode), check again. + CachedValue latest = cached; + if (latest != current) { + return latest.result; + } + return result; + } finally { + refreshing.set(false); + } + } + + // Another thread is refreshing — wait briefly then check. + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + CachedValue latest = cached; + if (latest != current && latest != null && !isExpired(latest)) { + return latest.result; + } + + // Still expired — return error. + return IdentityResult.ofError(getClass(), "Credentials are expired and refresh failed"); + } + + private IdentityResult doRefresh(Context requestProperties) { + CachedValue current = cached; + + // Stale delay: don't hammer the source (only in static stability mode). + if (allowExpiredCredentials && current != null + && current.nextRefreshAfter != null + && clock.instant().isBefore(current.nextRefreshAfter)) { + return current.result; + } + + IdentityResult result; + try { + result = delegate.resolveIdentity(requestProperties); + } catch (RuntimeException e) { + LOGGER.log(System.Logger.Level.WARNING, "Credential refresh failed", e); + if (current != null && allowExpiredCredentials) { + current.nextRefreshAfter = clock.instant().plus(jitteredStaleDelay()); + return current.result; + } + throw e; + } + + if (result.identity() != null) { + CachedValue newCached = new CachedValue<>(result.identity()); + cached = newCached; + scheduleNextRefresh(newCached, requestProperties); + return newCached.result; + } + + // Delegate returned an error. + if (current != null && allowExpiredCredentials) { + current.nextRefreshAfter = clock.instant().plus(jitteredStaleDelay()); + return current.result; + } + + return result; + } + + private void scheduleNextRefresh(CachedValue value, Context requestProperties) { + cancelScheduledRefresh(); + Instant expiration = value.identity.expirationTime(); + if (expiration == null) { + return; + } + + Instant refreshAt = expiration.minus(prefetchBuffer); + long delayMillis = Duration.between(clock.instant(), refreshAt).toMillis(); + if (delayMillis <= 0) { + // Already in prefetch window; refresh was just done. + return; + } + + scheduledRefresh = executor.schedule(() -> { + if (refreshing.compareAndSet(false, true)) { + try { + doRefresh(requestProperties); + } finally { + refreshing.set(false); + } + } + }, delayMillis, TimeUnit.MILLISECONDS); + } + + private void cancelScheduledRefresh() { + ScheduledFuture f = scheduledRefresh; + if (f != null) { + f.cancel(false); + scheduledRefresh = null; + } + } + + private boolean isInPrefetchWindow(CachedValue value) { + Instant expiration = value.identity.expirationTime(); + return expiration != null && clock.instant().isAfter(expiration.minus(prefetchBuffer)); + } + + private boolean isExpired(CachedValue value) { + Instant exp = value.identity.expirationTime(); + return exp != null && clock.instant().isAfter(exp); + } + + private Duration jitteredStaleDelay() { + long baseMillis = staleRefreshDelay.toMillis(); + long jitter = (long) (Math.random() * baseMillis); + return Duration.ofMillis(baseMillis + jitter); + } + + private static final class CachedValue { + final I identity; + final IdentityResult result; + volatile Instant nextRefreshAfter; + + CachedValue(I identity) { + this.identity = identity; + this.result = IdentityResult.of(identity); + } + } + + /** + * Builder for {@link CachingIdentityResolver}. + */ + public static final class Builder { + private final IdentityResolver delegate; + private Duration prefetchBuffer = Duration.ofMinutes(5); + private boolean allowExpiredCredentials = false; + private Duration staleRefreshDelay = Duration.ofMinutes(5); + private Clock clock = Clock.systemUTC(); + private ScheduledExecutorService executor; + + private Builder(IdentityResolver delegate) { + this.delegate = delegate; + } + + /** + * How far before expiration to trigger a background refresh. Default: 5 minutes. + */ + public Builder prefetchBuffer(Duration prefetchBuffer) { + this.prefetchBuffer = Objects.requireNonNull(prefetchBuffer); + return this; + } + + /** + * When {@code true}, expired credentials are returned instead of failing. Enables + * AWS Static Stability behavior. Default: {@code false}. + */ + public Builder allowExpiredCredentials(boolean allowExpiredCredentials) { + this.allowExpiredCredentials = allowExpiredCredentials; + return this; + } + + /** + * Base delay before retrying refresh when credentials are expired and refresh failed. + * Actual delay is jittered up to 2x this value. Default: 5 minutes. + */ + public Builder staleRefreshDelay(Duration staleRefreshDelay) { + this.staleRefreshDelay = Objects.requireNonNull(staleRefreshDelay); + return this; + } + + /** + * Clock for time comparisons. Default: {@link Clock#systemUTC()}. + */ + public Builder clock(Clock clock) { + this.clock = Objects.requireNonNull(clock); + return this; + } + + /** + * Executor for background refresh tasks. If not set, a single daemon thread is created + * and owned by this resolver (shut down on {@link CachingIdentityResolver#close()}). + */ + public Builder executor(ScheduledExecutorService executor) { + this.executor = Objects.requireNonNull(executor); + return this; + } + + public CachingIdentityResolver build() { + return new CachingIdentityResolver<>(this); + } + } +} diff --git a/auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/IdentityResolver.java b/auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/IdentityResolver.java index e41832999..61393ebd3 100644 --- a/auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/IdentityResolver.java +++ b/auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/IdentityResolver.java @@ -30,6 +30,18 @@ public interface IdentityResolver { */ Class identityType(); + /** + * Invalidate any cached identity, forcing the next call to {@link #resolveIdentity(Context)} to fetch fresh + * credentials from the underlying source. + * + *

This is typically called by retry logic or interceptors when a service returns an authentication error + * (e.g., {@code ExpiredTokenException}), indicating that the currently cached identity is no longer valid. + * + *

The default implementation is a no-op. Caching resolvers (such as {@link CachingIdentityResolver}) override + * this to clear their cache. + */ + default void invalidate() {} + /** * Combines multiple identity resolvers with the same identity type into a single resolver. * diff --git a/auth-api/src/test/java/software/amazon/smithy/java/auth/api/identity/CachingIdentityResolverTest.java b/auth-api/src/test/java/software/amazon/smithy/java/auth/api/identity/CachingIdentityResolverTest.java new file mode 100644 index 000000000..a36516f3a --- /dev/null +++ b/auth-api/src/test/java/software/amazon/smithy/java/auth/api/identity/CachingIdentityResolverTest.java @@ -0,0 +1,267 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.auth.api.identity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.java.context.Context; + +class CachingIdentityResolverTest { + + private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + private CachingIdentityResolver resolver; + + @AfterEach + void tearDown() { + if (resolver != null) { + resolver.close(); + } + executor.shutdownNow(); + } + + @Test + void coldStartBlocksAndCachesResult() { + var identity = new TestIdentity("cached", Instant.now().plusSeconds(3600)); + var delegate = new CountingResolver(identity); + resolver = CachingIdentityResolver.builder(delegate) + .executor(executor) + .build(); + + IdentityResult result = resolve(); + assertNotNull(result.identity()); + assertEquals("cached", result.identity().value); + assertEquals(1, delegate.callCount.get()); + + // Second call returns cached — no delegate invocation. + IdentityResult result2 = resolve(); + assertEquals("cached", result2.identity().value); + assertEquals(1, delegate.callCount.get()); + } + + @Test + void backgroundRefreshHappensBeforeExpiry() throws InterruptedException { + Instant now = Instant.now(); + // Credentials expire in 2 seconds, prefetch buffer is 1 second → refresh at T+1s. + var delegate = new CountingResolver(expiringIdentity(now.plusSeconds(2))); + resolver = CachingIdentityResolver.builder(delegate) + .executor(executor) + .prefetchBuffer(Duration.ofSeconds(1)) + .build(); + + // Cold start. + resolve(); + assertEquals(1, delegate.callCount.get()); + + // Wait for background refresh to fire (should happen ~1s from now). + Thread.sleep(1500); + assertTrue(delegate.callCount.get() >= 2, "Expected background refresh, got " + delegate.callCount.get()); + } + + @Test + void nonExpiringIdentityIsCachedIndefinitely() throws InterruptedException { + var delegate = new CountingResolver(new TestIdentity("permanent", null)); + resolver = CachingIdentityResolver.builder(delegate) + .executor(executor) + .build(); + + resolve(); + Thread.sleep(200); + resolve(); + assertEquals(1, delegate.callCount.get()); + } + + @Test + void allowExpiredCredentialsReturnsStaleOnFailure() { + AtomicInteger calls = new AtomicInteger(0); + IdentityResolver delegate = new IdentityResolver<>() { + @Override + public IdentityResult resolveIdentity(Context ctx) { + if (calls.incrementAndGet() == 1) { + return IdentityResult.of(expiringIdentity(Instant.now().minusSeconds(10))); + } + throw new RuntimeException("refresh failed"); + } + + @Override + public Class identityType() { + return TestIdentity.class; + } + }; + + // Use a fixed clock that's past expiration so the cache is immediately stale. + Clock pastClock = Clock.fixed(Instant.now().plusSeconds(60), ZoneId.of("UTC")); + resolver = CachingIdentityResolver.builder(delegate) + .executor(executor) + .allowExpiredCredentials(true) + .clock(pastClock) + .prefetchBuffer(Duration.ofSeconds(1)) + .build(); + + // Cold start succeeds (returns expired identity). + IdentityResult result = resolve(); + assertNotNull(result.identity()); + + // Trigger refresh — it fails, but we still get the stale value. + IdentityResult result2 = resolve(); + assertNotNull(result2.identity()); + } + + @Test + void strictModeReturnsErrorWhenExpiredAndRefreshFails() { + Instant expiration = Instant.now().plusMillis(50); // Expires very soon. + var identity = new TestIdentity("expiring", expiration); + AtomicInteger calls = new AtomicInteger(0); + IdentityResolver delegate = new IdentityResolver<>() { + @Override + public IdentityResult resolveIdentity(Context ctx) { + if (calls.incrementAndGet() == 1) { + return IdentityResult.of(identity); + } + return IdentityResult.ofError(getClass(), "no creds"); + } + + @Override + public Class identityType() { + return TestIdentity.class; + } + }; + + resolver = CachingIdentityResolver.builder(delegate) + .executor(executor) + .allowExpiredCredentials(false) + .prefetchBuffer(Duration.ofMillis(10)) + .build(); + + // Cold start succeeds. + IdentityResult first = resolve(); + assertNotNull(first.identity()); + + // Wait for expiration. + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + // Now expired + strict → blocks for retry → delegate returns error. + IdentityResult result = resolve(); + assertNull(result.identity()); + assertNotNull(result.error()); + } + + @Test + void invalidateForcesNextCallToRefresh() { + var delegate = new CountingResolver(expiringIdentity(Instant.now().plusSeconds(3600))); + resolver = CachingIdentityResolver.builder(delegate) + .executor(executor) + .build(); + + resolve(); + assertEquals(1, delegate.callCount.get()); + + resolver.invalidate(); + resolve(); + assertEquals(2, delegate.callCount.get()); + } + + @Test + void concurrentColdStartOnlyCallsDelegateOnce() throws InterruptedException { + CountDownLatch startGate = new CountDownLatch(1); + var delegate = new IdentityResolver() { + final AtomicInteger callCount = new AtomicInteger(0); + + @Override + public IdentityResult resolveIdentity(Context ctx) { + callCount.incrementAndGet(); + try { + Thread.sleep(100); // Simulate slow first fetch. + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return IdentityResult.of(new TestIdentity("shared", Instant.now().plusSeconds(3600))); + } + + @Override + public Class identityType() { + return TestIdentity.class; + } + }; + + resolver = CachingIdentityResolver.builder(delegate) + .executor(executor) + .build(); + + int threadCount = 10; + CountDownLatch done = new CountDownLatch(threadCount); + AtomicReference firstValue = new AtomicReference<>(); + + for (int i = 0; i < threadCount; i++) { + new Thread(() -> { + try { + startGate.await(); + } catch (InterruptedException e) { + return; + } + @SuppressWarnings("unchecked") + var r = (CachingIdentityResolver) resolver; + IdentityResult result = r.resolveIdentity(Context.empty()); + firstValue.compareAndSet(null, result.identity().value); + done.countDown(); + }).start(); + } + + startGate.countDown(); + assertTrue(done.await(5, TimeUnit.SECONDS)); + assertEquals(1, delegate.callCount.get()); + assertEquals("shared", firstValue.get()); + } + + @SuppressWarnings("unchecked") + private IdentityResult resolve() { + return ((CachingIdentityResolver) resolver).resolveIdentity(Context.empty()); + } + + private static TestIdentity expiringIdentity(Instant expiration) { + return new TestIdentity("id-" + System.nanoTime(), expiration); + } + + record TestIdentity(String value, Instant expirationTime) implements Identity {} + + static class CountingResolver implements IdentityResolver { + final AtomicInteger callCount = new AtomicInteger(0); + private final TestIdentity identity; + + CountingResolver(TestIdentity identity) { + this.identity = identity; + } + + @Override + public IdentityResult resolveIdentity(Context ctx) { + callCount.incrementAndGet(); + return IdentityResult.of(identity); + } + + @Override + public Class identityType() { + return TestIdentity.class; + } + } +} diff --git a/aws/aws-auth-api/src/main/java/software/amazon/smithy/java/aws/auth/api/identity/AwsCredentialsResolver.java b/aws/aws-auth-api/src/main/java/software/amazon/smithy/java/aws/auth/api/identity/AwsCredentialsResolver.java index 6fd2aa616..21fae6d20 100644 --- a/aws/aws-auth-api/src/main/java/software/amazon/smithy/java/aws/auth/api/identity/AwsCredentialsResolver.java +++ b/aws/aws-auth-api/src/main/java/software/amazon/smithy/java/aws/auth/api/identity/AwsCredentialsResolver.java @@ -9,6 +9,8 @@ /** * An {@link IdentityResolver} that resolves a {@link AwsCredentialsIdentity} for authentication. + * + *

Note: this is a convenience only. Do not rely on this for limiting subtypes. */ public interface AwsCredentialsResolver extends IdentityResolver { @Override diff --git a/aws/aws-config/README.md b/aws/aws-config/README.md new file mode 100644 index 000000000..ede16347f --- /dev/null +++ b/aws/aws-config/README.md @@ -0,0 +1,47 @@ +# AWS Config + +Provides credential resolution from AWS shared configuration files (`~/.aws/config` and `~/.aws/credentials`). Ships handlers for static keys, session keys, and credential_process. + +## Dependency + +```kotlin +dependencies { + implementation("software.amazon.smithy.java:aws-config:1.1.0") +} +``` + +## Usage + +Config file-based credential resolution is wired up automatically when `AwsCredentialChainPlugin` is installed on a client. This module registers its `ChainIdentityProvider` implementations via ServiceLoader, so no additional code is needed beyond adding the dependency. + +## Programmatic config file support + +You can load and query config files directly: + +```java +AwsProfileFile file = AwsProfileFile.load(); +AwsProfile profile = file.profile("default"); +String region = profile.property("region"); + +// Resolve credentials from a profile directly +var resolver = ProfileIdentityResolver.builder(AwsCredentialsIdentity.class) + .profileName("dev") + .build(); +IdentityResult result = resolver.resolveIdentity(Context.empty()); +``` + +## Supported credential sources + +This module handles the following profile credential sources via `ChainIdentityProvider.resolve()`: + +- **Static keys** — `aws_access_key_id` + `aws_secret_access_key` +- **Session keys** — static keys + `aws_session_token` +- **credential_process** — external program that outputs JSON credentials + +## Modular credential sources + +Additional sources (AssumeRole, SSO, WebIdentity, Login) are detected and typed by the chain module but require separate provider modules (`aws-credentials-sts`, `aws-credentials-sso`, etc.) to resolve. When a source is detected but no provider claims it, an actionable error names the missing dependency. + +## Extensibility + +Implement `ChainIdentityProvider.resolve()` in a new module and register via `META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider` to handle additional config-file source types. diff --git a/aws/aws-config/build.gradle.kts b/aws/aws-config/build.gradle.kts new file mode 100644 index 000000000..d0c0af27d --- /dev/null +++ b/aws/aws-config/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + id("smithy-java.module-conventions") + id("smithy-java.fuzz-test") +} + +description = "This module provides parsing of AWS shared config and credentials files " + + "(~/.aws/config, ~/.aws/credentials) and the profile data model." + +extra["displayName"] = "Smithy :: Java :: AWS :: Config" +extra["moduleName"] = "software.amazon.smithy.java.aws.config" + +dependencies { + api(project(":aws:aws-auth-api")) + implementation(project(":logging")) + testImplementation("tools.jackson.core:jackson-databind:3.1.2") +} diff --git a/aws/aws-config/src/fuzz/java/software/amazon/smithy/java/aws/config/AwsProfileFileParserFuzzTest.java b/aws/aws-config/src/fuzz/java/software/amazon/smithy/java/aws/config/AwsProfileFileParserFuzzTest.java new file mode 100644 index 000000000..bcbd23eea --- /dev/null +++ b/aws/aws-config/src/fuzz/java/software/amazon/smithy/java/aws/config/AwsProfileFileParserFuzzTest.java @@ -0,0 +1,25 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.config; + +import com.code_intelligence.jazzer.junit.FuzzTest; +import java.nio.charset.StandardCharsets; + +/** + * Fuzz test for the AWS config file parser. Ensures no input can cause unexpected exceptions, + * OOM, or infinite loops. The only acceptable exception is {@link ConfigFileParseException}. + */ +class AwsProfileFileParserFuzzTest { + + @FuzzTest(maxDuration = "5m") + void fuzzParser(byte[] data) { + try { + AwsProfileFileParser.parse(new String(data, StandardCharsets.UTF_8)); + } catch (ConfigFileParseException expected) { + // Valid failure mode — malformed input. + } + } +} diff --git a/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsConfigCredentialSource.java b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsConfigCredentialSource.java new file mode 100644 index 000000000..ce326c47a --- /dev/null +++ b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsConfigCredentialSource.java @@ -0,0 +1,202 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.config; + +import java.util.Map; +import java.util.Objects; + +/** + * AWS Config file credential types. + * + *

A {@link AwsProfile} exposes an ordered list of these sources (via {@link AwsProfile#credentialSources()}) + * computed from its raw properties. The order follows the AWS SDK shared-configuration specification's priority for + * credential-type selection, highest first. A profile that defines nothing credential-related will produce an empty + * list; most profiles produce exactly one source. + */ +public sealed interface AwsConfigCredentialSource { + /** + * Long-term credentials from {@code aws_access_key_id} + {@code aws_secret_access_key}. Used + * when no higher-priority source applies. + * + * @param accessKeyId the AWS access key ID. + * @param secretAccessKey the AWS secret access key. + * @param accountId the AWS account ID, or {@code null} if not specified. + */ + record StaticKeys(String accessKeyId, String secretAccessKey, String accountId) + implements AwsConfigCredentialSource { + public StaticKeys { + Objects.requireNonNull(accessKeyId, "accessKeyId"); + Objects.requireNonNull(secretAccessKey, "secretAccessKey"); + } + } + + /** + * Temporary credentials from {@code aws_access_key_id} + {@code aws_secret_access_key} + + * {@code aws_session_token}. Distinguished from {@link StaticKeys} by the presence of the + * session token. + * + * @param accessKeyId the AWS access key ID. + * @param secretAccessKey the AWS secret access key. + * @param sessionToken the AWS session token. + * @param accountId the AWS account ID, or {@code null} if not specified. + */ + record SessionKeys(String accessKeyId, String secretAccessKey, String sessionToken, String accountId) + implements AwsConfigCredentialSource { + public SessionKeys { + Objects.requireNonNull(accessKeyId, "accessKeyId"); + Objects.requireNonNull(secretAccessKey, "secretAccessKey"); + Objects.requireNonNull(sessionToken, "sessionToken"); + } + } + + /** + * Role assumption via {@code sts:AssumeRole}. + * + * @param roleArn the ARN of the role to assume. + * @param sourceProfile the name of the profile providing base credentials, or {@code null}. + * @param credentialSource the named credential source for base credentials, or {@code null}. + * @param externalId the external ID for the role assumption, or {@code null}. + * @param roleSessionName the session name for the assumed role, or {@code null}. + * @param mfaSerial the MFA device serial number, or {@code null}. + * @param durationSeconds the duration of the role session in seconds, or {@code null}. + * @param region the region to use for the STS call, or {@code null}. + */ + record AssumeRole( + String roleArn, + String sourceProfile, + String credentialSource, + String externalId, + String roleSessionName, + String mfaSerial, + Integer durationSeconds, + String region) implements AwsConfigCredentialSource { + public AssumeRole { + Objects.requireNonNull(roleArn, "roleArn"); + } + } + + /** + * Role assumption via {@code sts:AssumeRoleWithWebIdentity}. + * + * @param roleArn the ARN of the role to assume. + * @param webIdentityTokenFile the path to the file containing the web identity token. + * @param roleSessionName the session name for the assumed role, or {@code null}. + * @param region the region to use for the STS call, or {@code null}. + */ + record WebIdentityToken( + String roleArn, + String webIdentityTokenFile, + String roleSessionName, + String region) implements AwsConfigCredentialSource { + public WebIdentityToken { + Objects.requireNonNull(roleArn, "roleArn"); + Objects.requireNonNull(webIdentityTokenFile, "webIdentityTokenFile"); + } + } + + /** + * SSO-derived credentials via a named {@code [sso-session NAME]} section. + * + * @param sessionName the name of the {@code [sso-session]} section to use. + * @param accountId the AWS account ID to request credentials for. + * @param roleName the SSO role name to assume. + */ + record SsoSession(String sessionName, String accountId, String roleName) implements AwsConfigCredentialSource { + public SsoSession { + Objects.requireNonNull(sessionName, "sessionName"); + Objects.requireNonNull(accountId, "accountId"); + Objects.requireNonNull(roleName, "roleName"); + } + + static SsoSession fromProperties(Map p) { + String session = p.get("sso_session"); + String account = p.get("sso_account_id"); + String role = p.get("sso_role_name"); + if (session == null || session.isEmpty() + || account == null + || account.isEmpty() + || role == null + || role.isEmpty()) { + return null; + } + return new SsoSession(session, account, role); + } + } + + /** + * Legacy (pre-{@code sso-session}) SSO form where the start URL and region are inlined + * directly in the profile. + * + * @param startUrl the SSO start URL. + * @param region the SSO region. + * @param accountId the AWS account ID to request credentials for. + * @param roleName the SSO role name to assume. + */ + record LegacySso(String startUrl, String region, String accountId, String roleName) + implements AwsConfigCredentialSource { + public LegacySso { + Objects.requireNonNull(startUrl, "startUrl"); + Objects.requireNonNull(region, "region"); + Objects.requireNonNull(accountId, "accountId"); + Objects.requireNonNull(roleName, "roleName"); + } + + static LegacySso fromProperties(Map p) { + String url = p.get("sso_start_url"); + String region = p.get("sso_region"); + String account = p.get("sso_account_id"); + String role = p.get("sso_role_name"); + if (url == null || url.isEmpty() + || region == null + || region.isEmpty() + || account == null + || account.isEmpty() + || role == null + || role.isEmpty()) { + return null; + } + return new LegacySso(url, region, account, role); + } + } + + /** + * Credentials produced by invoking an external program configured by {@code credential_process}. + * + * @param commandLine the command to execute. + */ + record CredentialProcess(String commandLine) implements AwsConfigCredentialSource { + public CredentialProcess { + Objects.requireNonNull(commandLine, "commandLine"); + } + + static CredentialProcess fromProperties(Map p) { + String cmd = p.get("credential_process"); + if (cmd == null || cmd.isEmpty()) { + return null; + } + return new CredentialProcess(cmd); + } + } + + /** + * Credentials from an AWS Sign-In login session, configured via {@code login_session}. + * + * @param loginSession the login session identifier (typically an IAM user ARN). + */ + record LoginSession(String loginSession) implements AwsConfigCredentialSource { + public LoginSession { + Objects.requireNonNull(loginSession, "loginSession"); + } + + static LoginSession fromProperties(Map p) { + String session = p.get("login_session"); + if (session == null || session.isEmpty()) { + return null; + } + return new LoginSession(session); + } + } +} diff --git a/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsConfigFileType.java b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsConfigFileType.java new file mode 100644 index 000000000..a4a2b9468 --- /dev/null +++ b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsConfigFileType.java @@ -0,0 +1,17 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.config; + +/** + * Identifies which of the two AWS shared configuration files is being parsed. + */ +enum AwsConfigFileType { + /** The configuration file (e.g., {@code ~/.aws/config}). */ + CONFIGURATION, + + /** The shared credentials file (e.g., {@code ~/.aws/credentials}). */ + CREDENTIALS +} diff --git a/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsHomeResolver.java b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsHomeResolver.java new file mode 100644 index 000000000..036023254 --- /dev/null +++ b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsHomeResolver.java @@ -0,0 +1,117 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.config; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Locale; +import java.util.function.Function; + +/** + * Resolves the user's home directory using the same chain of environment variables described in + * the AWS SDK shared-configuration specification, and supports the tilde-expansion syntax used in + * the {@code AWS_CONFIG_FILE} / {@code AWS_SHARED_CREDENTIALS_FILE} environment variables. + * + *

Resolution order: + *

    + *
  1. The {@code HOME} environment variable, on any platform.
  2. + *
  3. On Windows platforms only: the {@code USERPROFILE} environment variable.
  4. + *
  5. On Windows platforms only: the concatenation of {@code HOMEDRIVE} and {@code HOMEPATH}.
  6. + *
  7. The {@code user.home} system property (the language-specific fallback permitted by the SEP).
  8. + *
+ * + *

When the platform cannot be determined, the Windows-specific variables are also inspected on + * non-Windows platforms, as allowed by the spec. + */ +final class AwsHomeResolver { + + private AwsHomeResolver() {} + + /** + * Resolve the current user's home directory using the default sources ({@link System#getenv(String)}, + * {@link System#getProperty(String)}). + * + * @return the resolved home directory, or {@code null} if it could not be determined. + */ + static Path resolveHome() { + return resolveHome(System::getenv, System::getProperty); + } + + /** + * Testable variant of {@link #resolveHome()} that takes injectable env and property getters. + * + * @param envGetter environment variable lookup. + * @param propertyGetter system property lookup (used for {@code os.name} and {@code user.home}). + * @return the resolved home directory, or {@code null} if none of the sources yielded a value. + */ + static Path resolveHome(Function envGetter, Function propertyGetter) { + String home = envGetter.apply("HOME"); + if (home != null && !home.isEmpty()) { + return Paths.get(home); + } + + boolean isWindows = isWindows(propertyGetter.apply("os.name")); + boolean platformUnknown = propertyGetter.apply("os.name") == null; + if (isWindows || platformUnknown) { + String userProfile = envGetter.apply("USERPROFILE"); + if (userProfile != null && !userProfile.isEmpty()) { + return Paths.get(userProfile); + } + String homeDrive = envGetter.apply("HOMEDRIVE"); + String homePath = envGetter.apply("HOMEPATH"); + if (homeDrive != null && !homeDrive.isEmpty() && homePath != null && !homePath.isEmpty()) { + return Paths.get(homeDrive + homePath); + } + } + + String userHome = propertyGetter.apply("user.home"); + if (userHome != null && !userHome.isEmpty()) { + return Paths.get(userHome); + } + + return null; + } + + /** + * Expand a leading {@code ~} or {@code ~/} in a path using the resolved home directory. + * + *

If the path does not begin with {@code ~}, it is returned unchanged. If the path begins + * with {@code ~} but home cannot be resolved, the path is returned unchanged (the file will + * simply fail to open later, which matches the SEP's "treat as empty" rule for inaccessible + * files). + * + *

This implementation does not support the {@code ~username/} form, which the SEP marks as + * a should rather than a must. + * + * @param rawPath a path potentially beginning with a tilde. + * @return the expanded path. + */ + static Path expandTilde(String rawPath) { + return expandTilde(rawPath, resolveHome()); + } + + static Path expandTilde(String rawPath, Path home) { + if (rawPath == null || rawPath.isEmpty()) { + return null; + } else if (rawPath.charAt(0) != '~' || home == null) { + return Paths.get(rawPath); + } else if (rawPath.length() == 1) { + return home; + } else { + char sep = rawPath.charAt(1); + if (sep == '/' || sep == '\\') { + String rest = rawPath.substring(2); + return rest.isEmpty() ? home : home.resolve(rest); + } + // "~username/..." — unsupported; leave alone per the SEP's "should, not must" guidance. + return Paths.get(rawPath); + } + } + + private static boolean isWindows(String osName) { + return osName != null && osName.toLowerCase(Locale.ROOT).contains("windows"); + } +} diff --git a/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsProfile.java b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsProfile.java new file mode 100644 index 000000000..dcb5e9057 --- /dev/null +++ b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsProfile.java @@ -0,0 +1,212 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.config; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +/** + * An AWS named profile, as parsed from {@code ~/.aws/config} or {@code ~/.aws/credentials}. + * + *

A profile has: + *

    + *
  • A name
  • + *
  • A flat map of scalar properties. Property keys are stored lower-cased and compared case-insensitively
  • + *
  • A map of sub-properties keyed by parent property name. Sub-properties represent the nested INI form + * {@code parent = \n child = value\n other = value}
  • + *
+ */ +public final class AwsProfile { + + private final String name; + private final Map properties; + private final Map> subProperties; + private final List credentialSources; + + AwsProfile(String name, Map properties, Map> subProperties) { + this.name = Objects.requireNonNull(name, "name"); + this.properties = Collections.unmodifiableMap(Objects.requireNonNull(properties, "properties")); + this.subProperties = Collections.unmodifiableMap(Objects.requireNonNull(subProperties, "subProperties")); + this.credentialSources = computeCredentialSources(); + } + + /** + * @return the profile name as it appeared in the file (for example {@code "default"} or {@code "dev"}). + */ + public String name() { + return name; + } + + /** + * @return an unmodifiable, insertion-ordered map of the profile's scalar properties. Keys are + * lower-cased. + */ + public Map properties() { + return properties; + } + + /** + * @return an unmodifiable, insertion-ordered map from parent property name to its sub-properties. + * Parent keys are lower-cased. Most profiles will not have any sub-properties. + */ + public Map> subProperties() { + return subProperties; + } + + /** + * Get a single property value. Keys are compared case-insensitively. + * + * @param key property name. + * @return the property value, or {@code null} if not set. + */ + public String property(String key) { + return properties.get(key.toLowerCase(Locale.ROOT)); + } + + /** + * Get the sub-properties for a parent property, if any. Keys are compared case-insensitively. + * + * @param key parent property name. + * @return the sub-property map, or {@code null} if no sub-properties exist under that key. + */ + public Map subProperties(String key) { + return subProperties.get(key.toLowerCase(Locale.ROOT)); + } + + /** + * Gets the computed the list of credential sources described by this profile, in AWS SDK shared-configuration + * priority order (highest priority first). + * + *

Every credential form a profile describes is returned, not only the highest priority. For example, a profile + * that sets both {@code role_arn} and {@code aws_access_key_id} produces a two-element list with an + * {@link AwsConfigCredentialSource.AssumeRole} first and an {@link AwsConfigCredentialSource.StaticKeys} second. + * This lets consumers iterate the list and dispatch to the first handler that can process an entry; callers that + * want strict priority can stop at index zero. + * + *

The mapping from raw properties to typed sources, in priority order, is: + *

    + *
  1. {@link AwsConfigCredentialSource.WebIdentityToken} when {@code role_arn} and + * {@code web_identity_token_file} are both set.
  2. + *
  3. {@link AwsConfigCredentialSource.AssumeRole} when {@code role_arn} is set and no + * {@code web_identity_token_file} is present.
  4. + *
  5. {@link AwsConfigCredentialSource.SsoSession} when {@code sso_session}, + * {@code sso_account_id}, and {@code sso_role_name} are all set.
  6. + *
  7. {@link AwsConfigCredentialSource.LegacySso} when the inline SSO keys + * ({@code sso_start_url}, {@code sso_region}, {@code sso_account_id}, + * {@code sso_role_name}) are all set.
  8. + *
  9. {@link AwsConfigCredentialSource.LoginSession} when {@code login_session} is set.
  10. + *
  11. {@link AwsConfigCredentialSource.CredentialProcess} when {@code credential_process} is set.
  12. + *
  13. {@link AwsConfigCredentialSource.SessionKeys} when {@code aws_access_key_id}, + * {@code aws_secret_access_key}, and {@code aws_session_token} are all set.
  14. + *
  15. {@link AwsConfigCredentialSource.StaticKeys} when {@code aws_access_key_id} and + * {@code aws_secret_access_key} are set (and no session token).
  16. + *
+ * + * @return an immutable, priority-ordered list of typed credential sources, possibly empty. + */ + public List credentialSources() { + return credentialSources; + } + + private List computeCredentialSources() { + List out = new ArrayList<>(2); + addIfNonNull(out, roleSource(properties)); + addIfNonNull(out, AwsConfigCredentialSource.SsoSession.fromProperties(properties)); + addIfNonNull(out, AwsConfigCredentialSource.LegacySso.fromProperties(properties)); + addIfNonNull(out, AwsConfigCredentialSource.LoginSession.fromProperties(properties)); + addIfNonNull(out, AwsConfigCredentialSource.CredentialProcess.fromProperties(properties)); + addIfNonNull(out, staticOrSessionKeys(properties)); + return Collections.unmodifiableList(out); + } + + /** + * Returns WebIdentityToken if both role_arn and web_identity_token_file are set, + * otherwise AssumeRole if role_arn is set, otherwise null. + */ + private static AwsConfigCredentialSource roleSource(Map p) { + String roleArn = p.get("role_arn"); + if (roleArn == null || roleArn.isEmpty()) { + return null; + } + String tokenFile = p.get("web_identity_token_file"); + if (tokenFile != null && !tokenFile.isEmpty()) { + return new AwsConfigCredentialSource.WebIdentityToken( + roleArn, + tokenFile, + p.get("role_session_name"), + p.get("region")); + } + return new AwsConfigCredentialSource.AssumeRole( + roleArn, + p.get("source_profile"), + p.get("credential_source"), + p.get("external_id"), + p.get("role_session_name"), + p.get("mfa_serial"), + parseIntOrNull(p.get("duration_seconds")), + p.get("region")); + } + + /** + * Returns SessionKeys if session token is present, StaticKeys if only access/secret are present, + * otherwise null. + */ + private static AwsConfigCredentialSource staticOrSessionKeys(Map p) { + String ak = p.get("aws_access_key_id"); + String sk = p.get("aws_secret_access_key"); + if (ak == null || ak.isEmpty() || sk == null || sk.isEmpty()) { + return null; + } + String token = p.get("aws_session_token"); + String accountId = p.get("aws_account_id"); + if (token != null && !token.isEmpty()) { + return new AwsConfigCredentialSource.SessionKeys(ak, sk, token, accountId); + } + return new AwsConfigCredentialSource.StaticKeys(ak, sk, accountId); + } + + private static Integer parseIntOrNull(String s) { + if (s == null || s.isEmpty()) { + return null; + } + try { + return Integer.valueOf(s); + } catch (NumberFormatException e) { + return null; + } + } + + private static void addIfNonNull(List list, AwsConfigCredentialSource source) { + if (source != null) { + list.add(source); + } + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof AwsProfile that)) { + return false; + } + return name.equals(that.name) && properties.equals(that.properties) && subProperties.equals(that.subProperties); + } + + @Override + public int hashCode() { + return Objects.hash(name, properties, subProperties); + } + + @Override + public String toString() { + return "AwsProfile[name=" + name + ", properties=" + properties + ", subProperties=" + subProperties + ']'; + } +} diff --git a/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsProfileFile.java b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsProfileFile.java new file mode 100644 index 000000000..31dc49a3a --- /dev/null +++ b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsProfileFile.java @@ -0,0 +1,391 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.config; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import software.amazon.smithy.java.context.Context; +import software.amazon.smithy.java.logging.InternalLogger; + +/** + * A mutable, in-memory view of the AWS shared {@code config} and {@code credentials} files, merged + * by profile name. + * + *

Behavior matches the AWS SDK shared-configuration specification: + *

    + *
  • Files are UTF-8 encoded. Non-existent files are treated as empty; inaccessible files + * result in an {@link UncheckedIOException}.
  • + *
  • Default paths are {@code ~/.aws/config} and {@code ~/.aws/credentials}, overridable via + * the {@code AWS_CONFIG_FILE} and {@code AWS_SHARED_CREDENTIALS_FILE} environment variables.
  • + *
  • Critical syntax errors cause {@link ConfigFileParseException} to be thrown.
  • + *
  • Property keys are case-insensitive and are stored lower-cased.
  • + *
  • When a profile is present in both files, the two profiles are merged: properties defined + * in the credentials file take precedence over the same property in the configuration file.
  • + *
  • In the configuration file, {@code [profile default]} supersedes {@code [default]} when + * both are present.
  • + *
+ * + *

Typical usage: + *

{@code
+ * AwsProfileFile profileFile = AwsProfileFile.load();
+ * AwsProfile defaultProfile = profileFile.profile("default");
+ * if (defaultProfile != null) {
+ *     String region = defaultProfile.property("region");
+ * }
+ *
+ * // Pick up edits on disk. Mutates this instance in place.
+ * profileFile.refresh();
+ * }
+ * + *

Thread-safety: reads after a call to {@link #refresh()} observe the new state + * atomically via a {@code volatile} internal reference. Callers that hold references to + * previously-returned profiles or profile lists are not affected by a later refresh; those views + * reflect the state at the time they were obtained. + */ +public final class AwsProfileFile { + + private static final InternalLogger LOGGER = InternalLogger.getLogger(AwsProfileFile.class); + + /** Environment variable overriding the config file path. */ + public static final String AWS_CONFIG_FILE_ENV = "AWS_CONFIG_FILE"; + + /** Environment variable overriding the credentials file path. */ + public static final String AWS_SHARED_CREDENTIALS_FILE_ENV = "AWS_SHARED_CREDENTIALS_FILE"; + + /** Context key for sharing a loaded profile file across providers in the chain. */ + public static final Context.Key CONTEXT_KEY = Context.key("awsProfileFile"); + + private final Path configFile; + private final Path credentialsFile; + private volatile State state; + + private AwsProfileFile( + Path configFile, + Path credentialsFile, + Map profiles, + Map ssoSessions + ) { + this.configFile = configFile; + this.credentialsFile = credentialsFile; + this.state = State.of(profiles, ssoSessions); + } + + /** + * Load an {@link AwsProfileFile} from the default paths. + * + *

The default paths are: + *

    + *
  • Config: the value of {@code AWS_CONFIG_FILE} if set, otherwise {@code ~/.aws/config}.
  • + *
  • Credentials: the value of {@code AWS_SHARED_CREDENTIALS_FILE} if set, otherwise + * {@code ~/.aws/credentials}.
  • + *
+ */ + public static AwsProfileFile load() { + return builder().build(); + } + + /** + * Loads the config/credentials files, returning {@code null} if neither exists or parsing fails. + */ + public static AwsProfileFile loadSilently() { + try { + return load(); + } catch (Exception e) { + return null; + } + } + + /** + * Returns the active profile based on {@code AWS_PROFILE} env var, {@code aws.profile} system property, + * or {@code "default"}. + * + * @return the active profile, or {@code null} if not found. + */ + public AwsProfile activeProfile() { + String name = System.getProperty("aws.profile"); + if (name == null) { + name = System.getenv("AWS_PROFILE"); + } + if (name == null) { + name = "default"; + } + return profile(name); + } + + /** + * @return a new builder. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * @return the config file path this instance was loaded from, or {@code null} if none was set. + */ + public Path configFile() { + return configFile; + } + + /** + * @return the credentials file path this instance was loaded from, or {@code null} if none was set. + */ + public Path credentialsFile() { + return credentialsFile; + } + + /** + * @return an immutable, insertion-ordered list of all profiles in this snapshot. The list + * reflects the state at the time of the call; a subsequent {@link #refresh()} does not + * affect an already-returned list. + */ + public List profiles() { + return state.profiles; + } + + /** + * @return an immutable, insertion-ordered list of profile names in this snapshot. + */ + public List profileNames() { + return state.profileNames; + } + + /** + * @return an immutable map of SSO session name to its profile-like data. Only populated from the + * configuration file; the credentials file does not support sso-session sections. + */ + public Map ssoSessions() { + return state.ssoSessions; + } + + /** + * Look up a profile by name. + * + * @param name the profile name. + * @return the profile, or {@code null} if no profile by that name is present. + */ + public AwsProfile profile(String name) { + return state.byName.get(Objects.requireNonNull(name, "name")); + } + + /** + * Re-read the config and credentials files from the paths this snapshot was loaded from and + * update this instance in place. Existing {@link AwsProfile} references previously handed out + * are not mutated; subsequent calls to {@link #profile(String)}, {@link #profiles()}, and + * {@link #profileNames()} reflect the new state. + */ + public void refresh() { + ProfileStandardizer.Result configResult = readAndStandardize(configFile, AwsConfigFileType.CONFIGURATION); + ProfileStandardizer.Result credResult = readAndStandardize(credentialsFile, AwsConfigFileType.CREDENTIALS); + this.state = State.of(mergeAcrossFiles(configResult.profiles(), credResult.profiles()), + configResult.ssoSessions()); + } + + /** + * Builder for {@link AwsProfileFile}. + * + *

If neither {@link #configFile(Path)} nor {@link #credentialsFile(Path)} is called, the + * builder falls back to the defaults described on {@link AwsProfileFile#load()}. Calling either + * setter with {@code null} disables that file entirely for this instance. + */ + public static final class Builder { + private Path explicitConfigFile; + private Path explicitCredentialsFile; + private boolean useDefaultConfigFile = true; + private boolean useDefaultCredentialsFile = true; + + private Builder() {} + + /** + * Use the given path as the config file. Pass {@code null} to disable reading a config file. + */ + public Builder configFile(Path path) { + this.explicitConfigFile = path; + this.useDefaultConfigFile = false; + return this; + } + + /** + * Use the given path as the credentials file. Pass {@code null} to disable reading a + * credentials file. + */ + public Builder credentialsFile(Path path) { + this.explicitCredentialsFile = path; + this.useDefaultCredentialsFile = false; + return this; + } + + /** + * Build the {@link AwsProfileFile}. + * + * @throws UncheckedIOException if a file exists but cannot be read. + * @throws ConfigFileParseException if either file contains a critical syntax error. + */ + public AwsProfileFile build() { + Path configPath = useDefaultConfigFile ? defaultConfigFilePath() : explicitConfigFile; + Path credentialsPath = useDefaultCredentialsFile ? defaultCredentialsFilePath() : explicitCredentialsFile; + + ProfileStandardizer.Result configResult = readAndStandardize(configPath, AwsConfigFileType.CONFIGURATION); + ProfileStandardizer.Result credResult = readAndStandardize(credentialsPath, AwsConfigFileType.CREDENTIALS); + Map merged = mergeAcrossFiles(configResult.profiles(), credResult.profiles()); + + // SSO sessions only come from the config file. + return new AwsProfileFile(configPath, credentialsPath, merged, configResult.ssoSessions()); + } + } + + private static Map mergeAcrossFiles( + Map configProfiles, + Map credProfiles + ) { + Map out = new LinkedHashMap<>(); + + for (Map.Entry e : configProfiles.entrySet()) { + String name = e.getKey(); + AwsProfile base = e.getValue(); + AwsProfile overlay = credProfiles.get(name); + if (overlay == null) { + out.put(name, base); + } else { + out.put(name, merge(base, overlay)); + } + } + for (Map.Entry e : credProfiles.entrySet()) { + if (!out.containsKey(e.getKey())) { + out.put(e.getKey(), e.getValue()); + } + } + return out; + } + + private static AwsProfile merge(AwsProfile base, AwsProfile overlay) { + Map props = new LinkedHashMap<>(base.properties()); + for (Map.Entry e : overlay.properties().entrySet()) { + String key = e.getKey(); + String oldValue = props.get(key); + if (oldValue != null && !oldValue.equals(e.getValue())) { + LOGGER.warn("Profile '{}' property '{}' from configuration file is shadowed by the " + + "credentials file.", base.name(), key); + } + props.put(key, e.getValue()); + } + + Map> subs = new LinkedHashMap<>(); + for (Map.Entry> e : base.subProperties().entrySet()) { + subs.put(e.getKey(), new LinkedHashMap<>(e.getValue())); + } + for (Map.Entry> e : overlay.subProperties().entrySet()) { + subs.put(e.getKey(), new LinkedHashMap<>(e.getValue())); + } + return new AwsProfile(base.name(), props, subs); + } + + private static ProfileStandardizer.Result readAndStandardize(Path path, AwsConfigFileType fileType) { + if (path == null) { + return new ProfileStandardizer.Result(Collections.emptyMap(), Collections.emptyMap()); + } + String content; + try { + content = Files.readString(path, StandardCharsets.UTF_8); + } catch (NoSuchFileException e) { + LOGGER.debug("AWS profile file does not exist: {}", path); + return new ProfileStandardizer.Result(Collections.emptyMap(), Collections.emptyMap()); + } catch (IOException e) { + throw new UncheckedIOException("Failed to read AWS profile file: " + path, e); + } + try { + var sections = AwsProfileFileParser.parse(content); + return ProfileStandardizer.standardize(sections, fileType); + } catch (ConfigFileParseException e) { + throw new ConfigFileParseException( + e.lineNumber(), + e.getMessage() + " (file: " + path + ")"); + } + } + + private static Path defaultConfigFilePath() { + String override = System.getenv(AWS_CONFIG_FILE_ENV); + if (override != null && !override.isEmpty()) { + return AwsHomeResolver.expandTilde(override); + } + Path home = AwsHomeResolver.resolveHome(); + return home == null ? Paths.get(".aws", "config") : home.resolve(".aws").resolve("config"); + } + + private static Path defaultCredentialsFilePath() { + String override = System.getenv(AWS_SHARED_CREDENTIALS_FILE_ENV); + if (override != null && !override.isEmpty()) { + return AwsHomeResolver.expandTilde(override); + } + Path home = AwsHomeResolver.resolveHome(); + return home == null ? Paths.get(".aws", "credentials") : home.resolve(".aws").resolve("credentials"); + } + + /** + * Resolve the default config and credentials file paths without reading them. + * Package-private for testing. + */ + static ResolvedPaths resolveDefaultPaths( + Function envGetter, + Function propertyGetter + ) { + Path home = AwsHomeResolver.resolveHome(envGetter, propertyGetter); + String configOverride = envGetter.apply(AWS_CONFIG_FILE_ENV); + String credsOverride = envGetter.apply(AWS_SHARED_CREDENTIALS_FILE_ENV); + Path config; + if (configOverride != null && !configOverride.isEmpty()) { + config = AwsHomeResolver.expandTilde(configOverride, home); + } else { + config = home == null ? Paths.get(".aws", "config") : home.resolve(".aws").resolve("config"); + } + Path creds; + if (credsOverride != null && !credsOverride.isEmpty()) { + creds = AwsHomeResolver.expandTilde(credsOverride, home); + } else { + creds = home == null ? Paths.get(".aws", "credentials") : home.resolve(".aws").resolve("credentials"); + } + return new ResolvedPaths(config, creds); + } + + record ResolvedPaths(Path configLocation, Path credentialsLocation) {} + + @Override + public String toString() { + return "AwsProfileFile[configFile=" + configFile + + ", credentialsFile=" + credentialsFile + + ", profiles=" + state.profileNames + ']'; + } + + /** + * Snapshot of the profile set; swapped atomically on {@link #refresh()}. + */ + private record State( + List profiles, + List profileNames, + Map byName, + Map ssoSessions) { + static State of(Map ordered, Map ssoSessions) { + List profiles = new ArrayList<>(ordered.values()); + List names = new ArrayList<>(ordered.keySet()); + return new State( + Collections.unmodifiableList(profiles), + Collections.unmodifiableList(names), + Collections.unmodifiableMap(new LinkedHashMap<>(ordered)), + Collections.unmodifiableMap(ssoSessions)); + } + } +} diff --git a/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsProfileFileParser.java b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsProfileFileParser.java new file mode 100644 index 000000000..9a4c54d6d --- /dev/null +++ b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsProfileFileParser.java @@ -0,0 +1,239 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.config; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; +import java.io.UncheckedIOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +/** + * Parser for the INI-style AWS shared configuration / credentials file format. + * + *

This parser produces a flat list of {@link RawSection raw sections} preserving the order of the + * original file. It is responsible for the grammar (which lines are valid and how values are constructed) but not + * for file-type specific validation (whether a section is allowed in this file, identifier validation, handling of + * {@code [default]} vs {@code [profile default]}, etc.); that belongs in {@link ProfileStandardizer}. + * + *

Grammar, matching the AWS SDK shared-configuration SEP: + *

    + *
  • Blank line: only whitespace. Ignored.
  • + *
  • Comment line: first character is {@code #} or {@code ;}. Ignored. (Leading whitespace + * before the marker is NOT a comment line; such a line is classified as continuation or as an + * unknown line depending on parser state.)
  • + *
  • Section header: {@code [ name ]} optionally followed by a comment + * ({@code ; ...} or {@code # ...}). A missing closing {@code ]} is a parse error.
  • + *
  • Property: {@code key = value} where {@code key} is at column 0 (no leading + * whitespace). The value is trimmed. If the value contains an unescaped {@code ;} preceded by + * whitespace, that {@code ;} and everything after it is a comment. Missing {@code =} or empty + * key is a parse error.
  • + *
  • Property continuation: a non-blank, non-comment line that starts with whitespace. + * Appended to the previous property's value with a leading newline. If the previous property + * had an empty value, the continuation is instead parsed as a sub-property + * ({@code key = value}), and subsequent indented lines under the same parent are also + * sub-properties.
  • + *
+ * + *

This parser preserves keys exactly as written; lower-casing is performed by + * {@link ProfileStandardizer}. + */ +final class AwsProfileFileParser { + + private AwsProfileFileParser() {} + + /** Matches just the bracketed portion of a section header, with groups for content and trailer. */ + private static final Pattern SECTION_PATTERN = Pattern.compile("^\\[(?[^]]*)](?.*)$"); + + /** + * Parse an AWS-style INI document. + * + * @param content the full text of the file. + * @return an ordered list of raw sections. + * @throws ConfigFileParseException on any critical syntax error. + */ + static List parse(String content) { + try (Reader reader = new StringReader(content)) { + return parse(reader); + } catch (IOException e) { + // StringReader.close() doesn't throw; this can't happen in practice. + throw new UncheckedIOException(e); + } + } + + /** + * Parse an AWS-style INI document from a reader. The caller is responsible for closing the reader. + */ + static List parse(Reader reader) throws IOException { + BufferedReader br = (reader instanceof BufferedReader) ? (BufferedReader) reader : new BufferedReader(reader); + List sections = new ArrayList<>(); + RawSection currentSection = null; + RawProperty currentProperty = null; + boolean inSubProperties = false; + + String line; + int lineNumber = 0; + while ((line = br.readLine()) != null) { + lineNumber++; + + // Strictly blank line. + if (line.isBlank()) { + continue; + } + + // Strict comment line: first char is '#' or ';' (no leading whitespace). + char firstChar = line.charAt(0); + if (firstChar == '#' || firstChar == ';') { + continue; + } + + if (firstChar == '[') { + // Section header: ends the previous section's sub-property / continuation state. + RawSection section = parseSectionHeader(line, lineNumber); + sections.add(section); + currentSection = section; + currentProperty = null; + inSubProperties = false; + continue; + } + + boolean startsWithWhitespace = firstChar == ' ' || firstChar == '\t'; + if (startsWithWhitespace) { + if (currentSection == null) { + throw new ConfigFileParseException(lineNumber, "Expected a section definition"); + } + if (currentProperty == null) { + throw new ConfigFileParseException(lineNumber, + "Expected a property definition, found continuation"); + } + if (inSubProperties || currentProperty.value.isEmpty()) { + // Sub-property line: key = value. + parseSubProperty(currentProperty, line, lineNumber); + inSubProperties = true; + } else { + // Plain continuation: append trimmed content with a leading newline. + // Inline comments are NOT stripped from continuations per the SEP. + currentProperty.value = currentProperty.value + "\n" + line.strip(); + } + continue; + } + + // Otherwise: must be a property definition at column 0. + if (currentSection == null) { + throw new ConfigFileParseException(lineNumber, "Expected a section definition"); + } + RawProperty prop = parseProperty(line, lineNumber); + // Duplicates within the same file and section: last write wins. Merge preserves + // insertion order of the first occurrence. + currentSection.properties.put(prop.key, prop); + currentProperty = prop; + inSubProperties = false; + } + + return sections; + } + + private static RawSection parseSectionHeader(String line, int lineNumber) { + var matcher = SECTION_PATTERN.matcher(line); + if (!matcher.matches()) { + throw new ConfigFileParseException(lineNumber, "Section definition must end with ']'"); + } + + String content = matcher.group("content").strip(); + String trailer = matcher.group("trailer"); + + // Trailer after ']' must be whitespace and/or a comment (# or ; based). + if (!trailer.isEmpty()) { + String t = trailer.stripLeading(); + if (!t.isEmpty() && t.charAt(0) != '#' && t.charAt(0) != ';') { + throw new ConfigFileParseException(lineNumber, "unexpected characters after ']' in section header"); + } + } + return new RawSection(lineNumber, content); + } + + private static RawProperty parseProperty(String line, int lineNumber) { + int eq = line.indexOf('='); + if (eq < 0) { + throw new ConfigFileParseException(lineNumber, "Expected an '=' sign defining a property"); + } + + String key = line.substring(0, eq).strip(); + if (key.isEmpty()) { + throw new ConfigFileParseException(lineNumber, "Property did not have a name"); + } + + String rawValue = line.substring(eq + 1); + String value = stripInlineComment(rawValue).strip(); + return new RawProperty(lineNumber, key, value); + } + + private static void parseSubProperty(RawProperty parent, String line, int lineNumber) { + int eq = line.indexOf('='); + if (eq < 0) { + throw new ConfigFileParseException(lineNumber, "Expected an '=' sign defining a property in sub-property"); + } + + String key = line.substring(0, eq).strip(); + if (key.isEmpty()) { + throw new ConfigFileParseException(lineNumber, "Property did not have a name in sub-property"); + } + + // Per the SEP, comments are NOT stripped from sub-property values. + String value = line.substring(eq + 1).strip(); + parent.subProperties.put(key, value); + } + + /** + * Strip an inline comment from a property value. Both {@code ;} and {@code #} count as inline + * comment markers when preceded by whitespace (space or tab). + */ + private static String stripInlineComment(String value) { + for (int i = 1; i < value.length(); i++) { + char c = value.charAt(i); + if (c == ';' || c == '#') { + char prev = value.charAt(i - 1); + if (prev == ' ' || prev == '\t') { + return value.substring(0, i); + } + } + } + + return value; + } + + /** An ordered, intermediate representation of one section of a parsed config/credentials file. */ + static final class RawSection { + final int lineNumber; + /** Raw content inside the brackets, already whitespace-trimmed. May contain a space (e.g. "profile foo"). */ + final String rawHeader; + final Map properties = new LinkedHashMap<>(); + + RawSection(int lineNumber, String rawHeader) { + this.lineNumber = lineNumber; + this.rawHeader = rawHeader; + } + } + + /** Intermediate representation of a single property within a section. */ + static final class RawProperty { + final int lineNumber; + final String key; + String value; + final Map subProperties = new LinkedHashMap<>(); + + RawProperty(int lineNumber, String key, String value) { + this.lineNumber = lineNumber; + this.key = key; + this.value = value; + } + } +} diff --git a/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/ConfigFileParseException.java b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/ConfigFileParseException.java new file mode 100644 index 000000000..99efef6ce --- /dev/null +++ b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/ConfigFileParseException.java @@ -0,0 +1,33 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.config; + +/** + * Thrown when an AWS shared config or credentials file cannot be parsed because it contains a + * critical syntax error. + */ +public final class ConfigFileParseException extends RuntimeException { + private final int lineNumber; + + ConfigFileParseException(int lineNumber, String message) { + super(formatMessage(lineNumber, message)); + this.lineNumber = lineNumber; + } + + /** + * @return the 1-based line number the error was detected on, or {@code -1} if unknown. + */ + public int lineNumber() { + return lineNumber; + } + + private static String formatMessage(int lineNumber, String message) { + if (lineNumber > 0) { + return "AWS config file parse error at line " + lineNumber + ": " + message; + } + return "AWS config file parse error: " + message; + } +} diff --git a/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/ProfileStandardizer.java b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/ProfileStandardizer.java new file mode 100644 index 000000000..1f8229b09 --- /dev/null +++ b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/ProfileStandardizer.java @@ -0,0 +1,255 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.config; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Pattern; +import software.amazon.smithy.java.aws.config.AwsProfileFileParser.RawProperty; +import software.amazon.smithy.java.aws.config.AwsProfileFileParser.RawSection; +import software.amazon.smithy.java.logging.InternalLogger; + +/** + * Applies file-type-specific standardization to the raw sections produced by + * {@link AwsProfileFileParser}, producing a map of profile name to {@link AwsProfile}. + * + *

Rules implemented (matching the AWS SDK shared-configuration SEP): + *

    + *
  • Identifier validation. Profile and property names must match the {@code Identifier} + * character class. Invalid names are silently dropped and a warning is logged.
  • + *
  • File-type-specific profile rules. In the configuration file, non-default profiles must be + * declared as {@code [profile name]}; sections without the prefix (other than + * {@code [default]}) are silently dropped. In the credentials file, sections whose name + * starts with {@code "profile "} are silently dropped.
  • + *
  • {@code [profile default]} supersedes {@code [default]} within the configuration file. If + * both are present, the {@code [default]}-named sections are dropped entirely.
  • + *
  • Profiles duplicated within the same file have their properties merged. Duplicate property + * keys within the same profile use the later value (last-write-wins). Keys are stored + * lower-cased and compared case-insensitively.
  • + *
  • {@code sso-session} sections are accepted only in the configuration file, and only when a + * non-empty name is supplied. {@code services} sections follow the same rule.
  • + *
+ */ +final class ProfileStandardizer { + + private static final InternalLogger LOGGER = InternalLogger.getLogger(ProfileStandardizer.class); + + /** Per the SEP revision log, identifiers may contain these characters. */ + private static final Pattern VALID_IDENTIFIER = Pattern.compile("^[A-Za-z0-9_\\-/.%@:+]+$"); + + /** The result of standardizing a parsed file. */ + record Result(Map profiles, Map ssoSessions) {} + + /** + * @param hadProfilePrefix True if this section was declared with a type prefix (for profile, "[profile x]"). + */ + private record Classification(SectionKind type, String name, boolean hadProfilePrefix) {} + + enum SectionKind { + PROFILE, SSO_SESSION, SERVICES + } + + private ProfileStandardizer() {} + + /** + * Standardize a parsed file's raw sections. + * + * @param sections the raw sections, in file order. + * @param fileType which of the two file types this content came from. + * @return the standardized result containing profiles and sso sessions. + */ + static Result standardize(List sections, AwsConfigFileType fileType) { + boolean hasExplicitProfileDefaultInConfig = false; + if (fileType == AwsConfigFileType.CONFIGURATION) { + for (RawSection s : sections) { + Classification c = classify(s, fileType, true); + if (c != null && c.type == SectionKind.PROFILE && "default".equals(c.name) && c.hadProfilePrefix) { + hasExplicitProfileDefaultInConfig = true; + break; + } + } + } + + Map> profileProps = new LinkedHashMap<>(); + Map>> profileSubs = new LinkedHashMap<>(); + Map> ssoProps = new LinkedHashMap<>(); + Map>> ssoSubs = new LinkedHashMap<>(); + + for (RawSection s : sections) { + Classification c = classify(s, fileType, false); + if (c == null) { + continue; + } + if (c.type == SectionKind.SSO_SESSION) { + mergeProperties(s, c.name, ssoProps, ssoSubs); + continue; + } + if (c.type != SectionKind.PROFILE) { + continue; + } + if (fileType == AwsConfigFileType.CONFIGURATION + && "default".equals(c.name) + && !c.hadProfilePrefix + && hasExplicitProfileDefaultInConfig) { + LOGGER.warn("Ignoring [default] section at line {}: [profile default] is also defined " + + "in the configuration file, which takes precedence.", s.lineNumber); + continue; + } + mergeProperties(s, c.name, profileProps, profileSubs); + } + + return new Result(buildProfiles(profileProps, profileSubs), buildProfiles(ssoProps, ssoSubs)); + } + + private static void mergeProperties( + RawSection section, + String name, + Map> propsMap, + Map>> subsMap + ) { + Map props = propsMap.computeIfAbsent(name, n -> new LinkedHashMap<>()); + Map> subs = subsMap.computeIfAbsent(name, n -> new LinkedHashMap<>()); + for (RawProperty p : section.properties.values()) { + String key = lowerCase(p.key); + if (!VALID_IDENTIFIER.matcher(key).matches()) { + LOGGER.warn("Ignoring property at line {}: key contains invalid characters.", p.lineNumber); + continue; + } + if (p.subProperties.isEmpty()) { + props.put(key, p.value); + subs.remove(key); + } else { + props.remove(key); + Map subMap = new LinkedHashMap<>(); + for (Map.Entry e : p.subProperties.entrySet()) { + String subKey = lowerCase(e.getKey()); + if (!VALID_IDENTIFIER.matcher(subKey).matches()) { + LOGGER.warn("Ignoring sub-property at line {}: key contains invalid characters.", + p.lineNumber); + continue; + } + subMap.put(subKey, e.getValue()); + } + subs.put(key, subMap); + } + } + } + + private static Map buildProfiles( + Map> propsMap, + Map>> subsMap + ) { + Map out = new LinkedHashMap<>(); + for (Map.Entry> e : propsMap.entrySet()) { + Map> subs = subsMap.getOrDefault(e.getKey(), Collections.emptyMap()); + out.put(e.getKey(), new AwsProfile(e.getKey(), e.getValue(), subs)); + } + return out; + } + + private static Classification classify(RawSection section, AwsConfigFileType fileType, boolean silent) { + String raw = section.rawHeader; + if (raw.isEmpty()) { + if (!silent) { + LOGGER.warn("Ignoring section at line {}: empty section name.", section.lineNumber); + } + return null; + } + + String[] parts = raw.split("\\s+", 2); + String typeToken = parts[0]; + String nameToken = parts.length > 1 ? parts[1].strip() : ""; + + // Type-prefixed section types. + if ("sso-session".equals(typeToken) || "services".equals(typeToken)) { + if (fileType == AwsConfigFileType.CREDENTIALS) { + if (!silent) { + LOGGER.warn("Ignoring [{} ...] section at line {}: not allowed in credentials file.", + typeToken, + section.lineNumber); + } + return null; + } else if (nameToken.isEmpty()) { + if (!silent) { + LOGGER.warn("Ignoring [{}] section at line {}: no name specified.", typeToken, section.lineNumber); + } + return null; + } else if (!VALID_IDENTIFIER.matcher(nameToken).matches()) { + if (!silent) { + LOGGER.warn("Ignoring [{} {}] section at line {}: name contains invalid characters.", + typeToken, + nameToken, + section.lineNumber); + } + return null; + } + + SectionKind kind = "sso-session".equals(typeToken) ? SectionKind.SSO_SESSION : SectionKind.SERVICES; + return new Classification(kind, nameToken, false); + } else if ("profile".equals(typeToken)) { + // A section of the form "[profile NAME]". + if (fileType == AwsConfigFileType.CREDENTIALS) { + if (!silent) { + LOGGER.warn("Ignoring section at line {}: profile names in the credentials file " + + "must not start with 'profile '.", section.lineNumber); + } + return null; + } + if (nameToken.isEmpty()) { + if (!silent) { + LOGGER.warn("Ignoring [profile] section at line {}: no profile name specified.", + section.lineNumber); + } + return null; + } + if (!VALID_IDENTIFIER.matcher(nameToken).matches()) { + if (!silent) { + LOGGER.warn("Ignoring [profile {}] section at line {}: name contains invalid characters.", + nameToken, + section.lineNumber); + } + return null; + } + return new Classification(SectionKind.PROFILE, nameToken, true); + } + + // Plain section: just an identifier. Use cases: + // - Credentials: any valid profile name. + // - Configuration: only [default] is valid without the "profile " prefix. + if (parts.length > 1) { + // "foo bar" with an unknown type token -> invalid. + if (!silent) { + LOGGER.warn("Ignoring section at line {}: unknown section type '{}'.", section.lineNumber, typeToken); + } + return null; + } else if (!VALID_IDENTIFIER.matcher(typeToken).matches()) { + if (!silent) { + LOGGER.warn("Ignoring section at line {}: profile name contains invalid characters.", + section.lineNumber); + } + return null; + } else if (fileType == AwsConfigFileType.CONFIGURATION) { + if (!"default".equals(typeToken)) { + if (!silent) { + LOGGER.warn("Ignoring [{}] section at line {}: in the configuration file only [default] " + + "may omit the 'profile' prefix.", typeToken, section.lineNumber); + } + return null; + } + return new Classification(SectionKind.PROFILE, "default", false); + } + + // Credentials file: any valid identifier is a profile name. + return new Classification(SectionKind.PROFILE, typeToken, false); + } + + private static String lowerCase(String s) { + return s.toLowerCase(Locale.ROOT); + } +} diff --git a/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/AwsHomeResolverTest.java b/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/AwsHomeResolverTest.java new file mode 100644 index 000000000..b27072ca6 --- /dev/null +++ b/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/AwsHomeResolverTest.java @@ -0,0 +1,117 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; +import org.junit.jupiter.api.Test; + +class AwsHomeResolverTest { + + @Test + void homeEnvVariableWinsOnAllPlatforms() { + Map env = Map.of("HOME", "/home/alice"); + Path home = AwsHomeResolver.resolveHome(env::get, propsFor("Mac OS X", null)); + assertEquals(Paths.get("/home/alice"), home); + } + + @Test + void userProfileIsIgnoredOnNonWindowsWhenPlatformKnown() { + Map env = new HashMap<>(); + env.put("USERPROFILE", "C:/Users/alice"); + env.put("HOMEDRIVE", "C:"); + env.put("HOMEPATH", "/Users/alice"); + Path home = AwsHomeResolver.resolveHome(env::get, propsFor("Linux", "/home/linux-user")); + assertEquals(Paths.get("/home/linux-user"), home); + } + + @Test + void userProfileUsedOnWindows() { + Map env = Map.of("USERPROFILE", "C:/Users/alice"); + Path home = AwsHomeResolver.resolveHome(env::get, propsFor("Windows 11", null)); + assertEquals(Paths.get("C:/Users/alice"), home); + } + + @Test + void homeDriveAndHomePathOnWindowsWhenUserProfileAbsent() { + Map env = Map.of("HOMEDRIVE", "D:", "HOMEPATH", "/Users/bob"); + Path home = AwsHomeResolver.resolveHome(env::get, propsFor("Windows 10", null)); + assertEquals(Paths.get("D:/Users/bob"), home); + } + + @Test + void platformUnknownFallsBackToWindowsVars() { + // SEP: if the platform is indeterminate, also consult USERPROFILE / HOMEDRIVE+HOMEPATH. + Map env = Map.of("USERPROFILE", "C:/Users/u"); + Path home = AwsHomeResolver.resolveHome(env::get, propsFor(null, null)); + assertEquals(Paths.get("C:/Users/u"), home); + } + + @Test + void userHomeSystemPropertyIsFinalFallback() { + Function noEnv = k -> null; + Path home = AwsHomeResolver.resolveHome(noEnv, propsFor("Linux", "/home/fallback")); + assertEquals(Paths.get("/home/fallback"), home); + } + + @Test + void nullReturnedWhenNothingResolves() { + Path home = AwsHomeResolver.resolveHome(k -> null, propsFor("Linux", null)); + assertNull(home); + } + + @Test + void tildeAloneExpandsToHome() { + assertEquals(Paths.get("/home/u"), AwsHomeResolver.expandTilde("~", Paths.get("/home/u"))); + } + + @Test + void tildeSlashExpandsToHomeSubpath() { + assertEquals(Paths.get("/home/u/.aws/config"), + AwsHomeResolver.expandTilde("~/.aws/config", Paths.get("/home/u"))); + } + + @Test + void tildeBackslashAlsoExpands() { + assertEquals(Paths.get("/home/u").resolve(".aws/config"), + AwsHomeResolver.expandTilde("~\\.aws/config", Paths.get("/home/u"))); + } + + @Test + void nonTildePathReturnedUnchanged() { + assertEquals(Paths.get("/tmp/x"), AwsHomeResolver.expandTilde("/tmp/x", Paths.get("/home/u"))); + } + + @Test + void tildeUsernameFormIsLeftAlone() { + // "~alice/..." is not supported (SEP marks it should, not must). + assertEquals(Paths.get("~alice/.aws/config"), + AwsHomeResolver.expandTilde("~alice/.aws/config", Paths.get("/home/u"))); + } + + @Test + void tildeWithoutHomeReturnedUnchanged() { + assertEquals(Paths.get("~/.aws/config"), + AwsHomeResolver.expandTilde("~/.aws/config", null)); + } + + private static Function propsFor(String osName, String userHome) { + Map props = new HashMap<>(); + if (osName != null) { + props.put("os.name", osName); + } + if (userHome != null) { + props.put("user.home", userHome); + } + return props::get; + } +} diff --git a/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/AwsProfileCredentialSourcesTest.java b/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/AwsProfileCredentialSourcesTest.java new file mode 100644 index 000000000..541b50846 --- /dev/null +++ b/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/AwsProfileCredentialSourcesTest.java @@ -0,0 +1,202 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class AwsProfileCredentialSourcesTest { + + @Test + void staticKeysOnly() { + AwsProfile p = profileFromContent(""" + [default] + aws_access_key_id = AK + aws_secret_access_key = SK + """); + List sources = p.credentialSources(); + assertEquals(1, sources.size()); + AwsConfigCredentialSource.StaticKeys s = + assertInstanceOf(AwsConfigCredentialSource.StaticKeys.class, sources.get(0)); + assertEquals("AK", s.accessKeyId()); + assertEquals("SK", s.secretAccessKey()); + assertNull(s.accountId()); + } + + @Test + void sessionKeysWhenSessionTokenPresent() { + AwsProfile p = profileFromContent(""" + [default] + aws_access_key_id = AK + aws_secret_access_key = SK + aws_session_token = ST + aws_account_id = 111111111111 + """); + AwsConfigCredentialSource source = p.credentialSources().get(0); + AwsConfigCredentialSource.SessionKeys s = assertInstanceOf(AwsConfigCredentialSource.SessionKeys.class, source); + assertEquals("AK", s.accessKeyId()); + assertEquals("SK", s.secretAccessKey()); + assertEquals("ST", s.sessionToken()); + assertEquals("111111111111", s.accountId()); + } + + @Test + void roleArnIsFirstButStaticKeysAlsoReturned() { + AwsProfile p = profileFromContent(""" + [default] + role_arn = arn:aws:iam::123:role/X + source_profile = base + aws_access_key_id = FALLBACK_AK + aws_secret_access_key = FALLBACK_SK + """); + List sources = p.credentialSources(); + assertEquals(2, sources.size()); + AwsConfigCredentialSource.AssumeRole r = + assertInstanceOf(AwsConfigCredentialSource.AssumeRole.class, sources.get(0)); + assertEquals("arn:aws:iam::123:role/X", r.roleArn()); + assertEquals("base", r.sourceProfile()); + AwsConfigCredentialSource.StaticKeys s = + assertInstanceOf(AwsConfigCredentialSource.StaticKeys.class, sources.get(1)); + assertEquals("FALLBACK_AK", s.accessKeyId()); + } + + @Test + void webIdentityWhenRoleArnAndTokenFilePresent() { + AwsProfile p = profileFromContent(""" + [default] + role_arn = arn:aws:iam::123:role/X + web_identity_token_file = /tmp/oidc-token + role_session_name = sess + """); + AwsConfigCredentialSource source = p.credentialSources().get(0); + AwsConfigCredentialSource.WebIdentityToken w = + assertInstanceOf(AwsConfigCredentialSource.WebIdentityToken.class, source); + assertEquals("arn:aws:iam::123:role/X", w.roleArn()); + assertEquals("/tmp/oidc-token", w.webIdentityTokenFile()); + assertEquals("sess", w.roleSessionName()); + } + + @Test + void ssoSessionFormWhenSessionNamed() { + AwsProfile p = profileFromContent(""" + [default] + sso_session = my-sess + sso_account_id = 111111111111 + sso_role_name = Dev + """); + AwsConfigCredentialSource source = p.credentialSources().get(0); + AwsConfigCredentialSource.SsoSession s = assertInstanceOf(AwsConfigCredentialSource.SsoSession.class, source); + assertEquals("my-sess", s.sessionName()); + assertEquals("111111111111", s.accountId()); + assertEquals("Dev", s.roleName()); + } + + @Test + void legacySsoWhenInlineStartUrlProvided() { + AwsProfile p = profileFromContent(""" + [default] + sso_start_url = https://corp.awsapps.com/start + sso_region = us-east-1 + sso_account_id = 111111111111 + sso_role_name = Dev + """); + AwsConfigCredentialSource source = p.credentialSources().get(0); + AwsConfigCredentialSource.LegacySso s = assertInstanceOf(AwsConfigCredentialSource.LegacySso.class, source); + assertEquals("https://corp.awsapps.com/start", s.startUrl()); + assertEquals("us-east-1", s.region()); + } + + @Test + void credentialProcessWhenNoHigherPriorityForm() { + AwsProfile p = profileFromContent(""" + [default] + credential_process = /usr/local/bin/awscreds --env=dev + """); + AwsConfigCredentialSource source = p.credentialSources().get(0); + AwsConfigCredentialSource.CredentialProcess s = + assertInstanceOf(AwsConfigCredentialSource.CredentialProcess.class, source); + assertEquals("/usr/local/bin/awscreds --env=dev", s.commandLine()); + } + + @Test + void emptyListWhenNoRecognizedCredentialProperties() { + AwsProfile p = profileFromContent(""" + [default] + region = us-east-1 + """); + assertTrue(p.credentialSources().isEmpty()); + } + + @Test + void durationSecondsParsedAsInteger() { + AwsProfile p = profileFromContent(""" + [default] + role_arn = arn:aws:iam::123:role/X + source_profile = base + duration_seconds = 3600 + """); + AwsConfigCredentialSource.AssumeRole r = + assertInstanceOf(AwsConfigCredentialSource.AssumeRole.class, p.credentialSources().get(0)); + assertEquals(3600, r.durationSeconds()); + } + + @Test + void badDurationSecondsIsSilentlyNull() { + AwsProfile p = profileFromContent(""" + [default] + role_arn = arn:aws:iam::123:role/X + source_profile = base + duration_seconds = not-a-number + """); + AwsConfigCredentialSource.AssumeRole r = + assertInstanceOf(AwsConfigCredentialSource.AssumeRole.class, p.credentialSources().get(0)); + assertNull(r.durationSeconds()); + } + + @Test + void loginSessionDetected() { + AwsProfile p = profileFromContent(""" + [default] + login_session = arn:aws:iam::0123456789012:user/Admin + """); + List sources = p.credentialSources(); + assertEquals(1, sources.size()); + AwsConfigCredentialSource.LoginSession s = + assertInstanceOf(AwsConfigCredentialSource.LoginSession.class, sources.get(0)); + assertEquals("arn:aws:iam::0123456789012:user/Admin", s.loginSession()); + } + + @Test + void multipleSourcesReturnedInPriorityOrder() { + AwsProfile p = profileFromContent(""" + [default] + sso_session = corp + sso_account_id = 111111111111 + sso_role_name = Dev + credential_process = /usr/bin/get-creds + aws_access_key_id = AK + aws_secret_access_key = SK + """); + List sources = p.credentialSources(); + assertEquals(3, sources.size()); + assertInstanceOf(AwsConfigCredentialSource.SsoSession.class, sources.get(0)); + assertInstanceOf(AwsConfigCredentialSource.CredentialProcess.class, sources.get(1)); + assertInstanceOf(AwsConfigCredentialSource.StaticKeys.class, sources.get(2)); + } + + private static AwsProfile profileFromContent(String content) { + Map profiles = ProfileStandardizer.standardize( + AwsProfileFileParser.parse(content), + AwsConfigFileType.CREDENTIALS).profiles(); + return profiles.get("default"); + } +} diff --git a/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/AwsProfileFileParserTest.java b/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/AwsProfileFileParserTest.java new file mode 100644 index 000000000..612a8991f --- /dev/null +++ b/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/AwsProfileFileParserTest.java @@ -0,0 +1,246 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.java.aws.config.AwsProfileFileParser.RawProperty; +import software.amazon.smithy.java.aws.config.AwsProfileFileParser.RawSection; + +class AwsProfileFileParserTest { + + @Test + void parsesSimpleSectionsAndProperties() { + String content = """ + [default] + aws_access_key_id = AKIA_DEFAULT + aws_secret_access_key = default_secret + + [profile dev] + region = us-west-2 + """; + + List sections = AwsProfileFileParser.parse(content); + assertEquals(2, sections.size()); + + RawSection def = sections.get(0); + assertEquals("default", def.rawHeader); + assertEquals(List.of("aws_access_key_id", "aws_secret_access_key"), List.copyOf(def.properties.keySet())); + assertEquals("AKIA_DEFAULT", def.properties.get("aws_access_key_id").value); + + RawSection dev = sections.get(1); + assertEquals("profile dev", dev.rawHeader); + assertEquals("us-west-2", dev.properties.get("region").value); + } + + @Test + void ignoresCommentsAndBlankLines() { + String content = """ + # top comment + ; another one + + [default] + # inside a section + ; also a comment + region = us-east-1 + """; + + List sections = AwsProfileFileParser.parse(content); + assertEquals(1, sections.size()); + assertEquals(Map.of("region", "us-east-1"), + mapValues(sections.get(0).properties)); + } + + @Test + void acceptsSectionHeaderWithTrailingComment() { + assertEquals("default", AwsProfileFileParser.parse("[default]; hi\n").get(0).rawHeader); + assertEquals("profile foo", AwsProfileFileParser.parse("[profile foo] # hello\n").get(0).rawHeader); + } + + @Test + void duplicateKeysLastWriteWinsAndPreserveOrder() { + String content = """ + [default] + region = us-east-1 + region = us-west-2 + """; + RawSection s = AwsProfileFileParser.parse(content).get(0); + assertEquals("us-west-2", s.properties.get("region").value); + } + + @Test + void stripsInlineSemicolonCommentFromValuesOnly() { + String content = """ + [default] + a = hello ; comment + b = hello;not-a-comment + c = with#hash + d = val\twith\ttabs ; cmt + """; + RawSection s = AwsProfileFileParser.parse(content).get(0); + assertEquals("hello", s.properties.get("a").value); + assertEquals("hello;not-a-comment", s.properties.get("b").value); + assertEquals("with#hash", s.properties.get("c").value); + assertEquals("val\twith\ttabs", s.properties.get("d").value); + } + + @Test + void propertyContinuationAppendsWithNewline() { + String content = """ + [default] + region = us- + west-2 + """; + RawSection s = AwsProfileFileParser.parse(content).get(0); + assertEquals("us-\nwest-2", s.properties.get("region").value); + } + + @Test + void propertyContinuationDoesNotStripInlineComments() { + String content = """ + [default] + region = us- + west-2 ; comment becomes part of the value + """; + RawSection s = AwsProfileFileParser.parse(content).get(0); + assertEquals("us-\nwest-2 ; comment becomes part of the value", + s.properties.get("region").value); + } + + @Test + void subPropertyUnderEmptyParent() { + String content = """ + [default] + s3 = + max_concurrent_requests = 30 + max_retries = 10 + region = us-west-2 + """; + RawSection s = AwsProfileFileParser.parse(content).get(0); + assertEquals("", s.properties.get("s3").value); + assertEquals(Map.of("max_concurrent_requests", "30", "max_retries", "10"), + s.properties.get("s3").subProperties); + assertEquals("us-west-2", s.properties.get("region").value); + } + + @Test + void multipleSubPropertyBlocksInSameProfile() { + String content = """ + [default] + s3 = + max_concurrent_requests = 30 + dynamodb = + endpoint_url = https://localhost:1234 + """; + RawSection s = AwsProfileFileParser.parse(content).get(0); + assertEquals(Map.of("max_concurrent_requests", "30"), s.properties.get("s3").subProperties); + assertEquals(Map.of("endpoint_url", "https://localhost:1234"), + s.properties.get("dynamodb").subProperties); + } + + @Test + void windowsLineEndingsAreHandled() { + String content = "[default]\r\nregion = us-east-1\r\n"; + RawSection s = AwsProfileFileParser.parse(content).get(0); + assertEquals("us-east-1", s.properties.get("region").value); + } + + // --- Fail-fast cases ------------------------------------------------------------------------ + + @Test + void sectionWithoutClosingBracketFails() { + ConfigFileParseException e = assertThrows( + ConfigFileParseException.class, + () -> AwsProfileFileParser.parse("[default\nregion = us-east-1\n")); + assertEquals(1, e.lineNumber()); + } + + @Test + void propertyBeforeAnySectionFails() { + ConfigFileParseException e = assertThrows( + ConfigFileParseException.class, + () -> AwsProfileFileParser.parse("region = us-east-1\n[default]\n")); + assertEquals(1, e.lineNumber()); + } + + @Test + void propertyWithoutEqualsFails() { + ConfigFileParseException e = assertThrows( + ConfigFileParseException.class, + () -> AwsProfileFileParser.parse("[default]\nregion\n")); + assertEquals(2, e.lineNumber()); + } + + @Test + void propertyWithoutKeyBeforeEqualsFails() { + ConfigFileParseException e = assertThrows( + ConfigFileParseException.class, + () -> AwsProfileFileParser.parse("[default]\n= us-east-1\n")); + assertEquals(2, e.lineNumber()); + } + + @Test + void continuationBeforeAnyPropertyFails() { + ConfigFileParseException e = assertThrows( + ConfigFileParseException.class, + () -> AwsProfileFileParser.parse("[default]\n continued\n")); + assertEquals(2, e.lineNumber()); + } + + @Test + void continuationOfEmptyValueWithoutEqualsFails() { + ConfigFileParseException e = assertThrows( + ConfigFileParseException.class, + () -> AwsProfileFileParser.parse("[default]\ns3 =\n notanassignment\n")); + assertEquals(3, e.lineNumber()); + } + + @Test + void continuationOfEmptyValueWithoutKeyFails() { + ConfigFileParseException e = assertThrows( + ConfigFileParseException.class, + () -> AwsProfileFileParser.parse("[default]\ns3 =\n = 30\n")); + assertEquals(3, e.lineNumber()); + } + + @Test + void textAfterSectionHeaderFails() { + // e.g. "[default] extra" — anything after ']' other than whitespace+comment is invalid. + ConfigFileParseException e = assertThrows( + ConfigFileParseException.class, + () -> AwsProfileFileParser.parse("[default] extra\n")); + assertEquals(1, e.lineNumber()); + } + + @Test + void whitespaceOnlyLineWithinSubPropertiesIsBlank() { + // Blank lines between sub-properties are permitted per the SEP. + String content = """ + [default] + s3 = + max_concurrent_requests = 30 + + max_retries = 10 + """; + RawSection s = AwsProfileFileParser.parse(content).get(0); + Map subs = s.properties.get("s3").subProperties; + assertTrue(subs.containsKey("max_concurrent_requests")); + assertTrue(subs.containsKey("max_retries")); + } + + private static Map mapValues(Map props) { + Map out = new java.util.LinkedHashMap<>(); + for (var e : props.entrySet()) { + out.put(e.getKey(), e.getValue().value); + } + return out; + } +} diff --git a/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/AwsProfileFileTest.java b/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/AwsProfileFileTest.java new file mode 100644 index 000000000..a469c1bf4 --- /dev/null +++ b/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/AwsProfileFileTest.java @@ -0,0 +1,248 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class AwsProfileFileTest { + + @Test + void mergesConfigAndCredentialsWithCredentialsTakingPrecedence(@TempDir Path tmp) throws IOException { + Path config = tmp.resolve("config"); + Path creds = tmp.resolve("credentials"); + + Files.writeString(config, """ + [default] + region = us-east-1 + aws_access_key_id = CONFIG_KEY + aws_secret_access_key = CONFIG_SECRET + + [profile dev] + region = us-west-2 + """, StandardCharsets.UTF_8); + + Files.writeString(creds, """ + [default] + aws_access_key_id = CREDS_KEY + aws_secret_access_key = CREDS_SECRET + aws_session_token = CREDS_TOKEN + + [dev] + aws_access_key_id = DEV_KEY + aws_secret_access_key = DEV_SECRET + """, StandardCharsets.UTF_8); + + AwsProfileFile file = AwsProfileFile.builder() + .configFile(config) + .credentialsFile(creds) + .build(); + + assertEquals(2, file.profiles().size()); + + AwsProfile def = file.profile("default"); + assertNotNull(def); + assertEquals("us-east-1", def.property("region")); + assertEquals("CREDS_KEY", def.property("aws_access_key_id")); + assertEquals("CREDS_SECRET", def.property("aws_secret_access_key")); + assertEquals("CREDS_TOKEN", def.property("aws_session_token")); + + AwsProfile dev = file.profile("dev"); + assertNotNull(dev); + assertEquals("us-west-2", dev.property("region")); + assertEquals("DEV_KEY", dev.property("aws_access_key_id")); + } + + @Test + void missingFilesAreTreatedAsEmpty(@TempDir Path tmp) { + Path config = tmp.resolve("does-not-exist-config"); + Path creds = tmp.resolve("does-not-exist-credentials"); + + AwsProfileFile file = AwsProfileFile.builder() + .configFile(config) + .credentialsFile(creds) + .build(); + + assertTrue(file.profiles().isEmpty()); + assertNull(file.profile("default")); + } + + @Test + void refreshMutatesInPlace(@TempDir Path tmp) throws IOException { + Path creds = tmp.resolve("credentials"); + Files.writeString(creds, """ + [default] + aws_access_key_id = V1 + aws_secret_access_key = V1_SECRET + """, StandardCharsets.UTF_8); + + AwsProfileFile file = AwsProfileFile.builder() + .configFile(null) + .credentialsFile(creds) + .build(); + + // First snapshot. + AwsProfile before = file.profile("default"); + assertEquals("V1", before.property("aws_access_key_id")); + + Files.writeString(creds, """ + [default] + aws_access_key_id = V2 + aws_secret_access_key = V2_SECRET + """, StandardCharsets.UTF_8); + + file.refresh(); + + // After refresh, the file yields a new snapshot. + AwsProfile after = file.profile("default"); + assertEquals("V2", after.property("aws_access_key_id")); + // The previously-returned AwsProfile is immutable and unaffected. + assertEquals("V1", before.property("aws_access_key_id")); + } + + @Test + void profilesListReturnedInFileOrder(@TempDir Path tmp) throws IOException { + Path config = tmp.resolve("config"); + Path creds = tmp.resolve("credentials"); + Files.writeString(config, """ + [default] + region = us-east-1 + + [profile beta] + region = us-east-2 + """, StandardCharsets.UTF_8); + Files.writeString(creds, """ + [alpha] + aws_access_key_id = A + aws_secret_access_key = AS + + [beta] + aws_access_key_id = B + aws_secret_access_key = BS + """, StandardCharsets.UTF_8); + + AwsProfileFile file = AwsProfileFile.builder() + .configFile(config) + .credentialsFile(creds) + .build(); + + List names = new ArrayList<>(); + for (AwsProfile p : file.profiles()) { + names.add(p.name()); + } + assertEquals(List.of("default", "beta", "alpha"), names); + assertEquals(List.of("default", "beta", "alpha"), file.profileNames()); + } + + @Test + void subPropertiesFromConfigFileSurvive(@TempDir Path tmp) throws IOException { + Path config = tmp.resolve("config"); + Files.writeString(config, """ + [profile ddb] + region = us-west-2 + dynamodb = + endpoint_url = https://localhost:8000 + """, StandardCharsets.UTF_8); + + AwsProfileFile file = AwsProfileFile.builder() + .configFile(config) + .credentialsFile(null) + .build(); + + AwsProfile ddb = file.profile("ddb"); + assertNotNull(ddb); + assertEquals("us-west-2", ddb.property("region")); + assertEquals("https://localhost:8000", ddb.subProperties("dynamodb").get("endpoint_url")); + } + + @Test + void propertyKeysAreCaseInsensitive(@TempDir Path tmp) throws IOException { + Path config = tmp.resolve("config"); + Files.writeString(config, """ + [default] + REGION = us-east-1 + """, StandardCharsets.UTF_8); + AwsProfileFile file = AwsProfileFile.builder() + .configFile(config) + .credentialsFile(null) + .build(); + AwsProfile def = file.profile("default"); + assertEquals("us-east-1", def.property("region")); + assertEquals("us-east-1", def.property("Region")); + } + + @Test + void parseErrorsIncludeFilePathAndLineNumber(@TempDir Path tmp) throws IOException { + Path config = tmp.resolve("config"); + Files.writeString(config, "[profile dev\nregion = us-west-2\n", StandardCharsets.UTF_8); + + ConfigFileParseException e = assertThrows(ConfigFileParseException.class, + () -> AwsProfileFile.builder() + .configFile(config) + .credentialsFile(null) + .build()); + assertEquals(1, e.lineNumber()); + assertTrue(e.getMessage().contains(config.toString())); + } + + @Test + void ssoSessionsExposedFromConfigFile(@TempDir Path tmp) throws IOException { + Path config = tmp.resolve("config"); + Files.writeString(config, """ + [default] + region = us-east-1 + + [sso-session corp] + sso_region = us-west-2 + sso_start_url = https://corp.awsapps.com/start + """, StandardCharsets.UTF_8); + + AwsProfileFile file = AwsProfileFile.builder() + .configFile(config) + .credentialsFile(null) + .build(); + + assertNotNull(file.ssoSessions()); + assertEquals(1, file.ssoSessions().size()); + AwsProfile session = file.ssoSessions().get("corp"); + assertNotNull(session); + assertEquals("us-west-2", session.property("sso_region")); + assertEquals("https://corp.awsapps.com/start", session.property("sso_start_url")); + } + + @Test + void onlyCredentialsFileWorks(@TempDir Path tmp) throws IOException { + Path creds = tmp.resolve("credentials"); + Files.writeString(creds, """ + [default] + aws_access_key_id = ONLY + aws_secret_access_key = ONLY_SECRET + """, StandardCharsets.UTF_8); + + AwsProfileFile file = AwsProfileFile.builder() + .configFile(null) + .credentialsFile(creds) + .build(); + + assertEquals(1, file.profiles().size()); + assertNotNull(file.profile("default")); + assertNull(file.profile("missing")); + assertFalse(file.profiles().isEmpty()); + } +} diff --git a/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/ProfileStandardizerTest.java b/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/ProfileStandardizerTest.java new file mode 100644 index 000000000..88b9e4fdc --- /dev/null +++ b/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/ProfileStandardizerTest.java @@ -0,0 +1,187 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ProfileStandardizerTest { + + @Test + void lowerCasesKeysAndPreservesValues() { + String content = """ + [default] + REGION = us-east-1 + AWS_Access_Key_Id = AKIA + """; + Map profiles = standardize(content, AwsConfigFileType.CREDENTIALS); + assertEquals("us-east-1", profiles.get("default").property("region")); + assertEquals("us-east-1", profiles.get("default").property("Region")); + assertEquals("AKIA", profiles.get("default").property("aws_access_key_id")); + } + + @Test + void configFileDropsNonDefaultSectionsWithoutProfilePrefix() { + String content = """ + [default] + region = us-east-1 + + [foo] + region = us-west-2 + """; + Map profiles = standardize(content, AwsConfigFileType.CONFIGURATION); + assertEquals(List.of("default"), List.copyOf(profiles.keySet())); + } + + @Test + void configFileAcceptsProfilePrefixedSections() { + String content = """ + [default] + region = us-east-1 + + [profile foo] + region = us-west-2 + """; + Map profiles = standardize(content, AwsConfigFileType.CONFIGURATION); + assertEquals(List.of("default", "foo"), List.copyOf(profiles.keySet())); + } + + @Test + void credentialsFileRejectsProfilePrefix() { + String content = """ + [default] + aws_access_key_id = A + + [profile foo] + aws_access_key_id = B + """; + Map profiles = standardize(content, AwsConfigFileType.CREDENTIALS); + assertEquals(List.of("default"), List.copyOf(profiles.keySet())); + } + + @Test + void profileDefaultSupersedesDefaultInConfigFile() { + String content = """ + [default] + aws_access_key_id = A + aws_secret_access_key = S + region = us-west-1 + + [profile default] + aws_access_key_id = B + + [profile default] + aws_secret_access_key = T + + [default] + region = us-west-1 + """; + Map profiles = standardize(content, AwsConfigFileType.CONFIGURATION); + AwsProfile d = profiles.get("default"); + assertEquals("B", d.property("aws_access_key_id")); + assertEquals("T", d.property("aws_secret_access_key")); + assertNull(d.property("region")); + } + + @Test + void duplicateProfilesInSameFileAreMerged() { + String content = """ + [default] + aws_access_key_id = A + + [default] + aws_secret_access_key = S + """; + Map profiles = standardize(content, AwsConfigFileType.CREDENTIALS); + assertEquals("A", profiles.get("default").property("aws_access_key_id")); + assertEquals("S", profiles.get("default").property("aws_secret_access_key")); + } + + @Test + void invalidProfileNameIsSilentlyDropped() { + String content = """ + [default] + aws_access_key_id = A + + [not valid] + aws_access_key_id = IGNORED + """; + Map profiles = standardize(content, AwsConfigFileType.CREDENTIALS); + assertEquals(List.of("default"), List.copyOf(profiles.keySet())); + } + + @Test + void invalidPropertyNameIsSilentlyDropped() { + String content = """ + [default] + region = us-east-1 + bad key = dropped + aws_access_key_id = A + """; + Map profiles = standardize(content, AwsConfigFileType.CREDENTIALS); + AwsProfile d = profiles.get("default"); + assertEquals("us-east-1", d.property("region")); + assertEquals("A", d.property("aws_access_key_id")); + assertNull(d.property("bad key")); + } + + @Test + void ssoSessionAndServicesOnlyInConfigFile() { + String content = """ + [default] + region = us-east-1 + + [sso-session my-session] + sso_start_url = https://example.awsapps.com/start + + [services my-services] + dynamodb = + endpoint_url = https://localhost:8000 + """; + Map configProfiles = standardize(content, AwsConfigFileType.CONFIGURATION); + assertEquals(List.of("default"), List.copyOf(configProfiles.keySet())); + Map credProfiles = standardize(content, AwsConfigFileType.CREDENTIALS); + assertEquals(List.of("default"), List.copyOf(credProfiles.keySet())); + } + + @Test + void subPropertiesExposedOnAwsProfile() { + String content = """ + [default] + s3 = + max_concurrent_requests = 30 + max_retries = 10 + """; + Map profiles = standardize(content, AwsConfigFileType.CONFIGURATION); + AwsProfile d = profiles.get("default"); + Map subs = d.subProperties("s3"); + assertNotNull(subs); + assertEquals(Map.of("max_concurrent_requests", "30", "max_retries", "10"), subs); + // Case-insensitive lookup works for sub-property parents too. + assertNotNull(d.subProperties("S3")); + } + + @Test + void emptySectionNameIsDropped() { + String content = """ + [] + region = ignored + [default] + region = us-east-1 + """; + Map profiles = standardize(content, AwsConfigFileType.CREDENTIALS); + assertEquals(List.of("default"), List.copyOf(profiles.keySet())); + } + + private static Map standardize(String content, AwsConfigFileType fileType) { + return ProfileStandardizer.standardize(AwsProfileFileParser.parse(content), fileType).profiles(); + } +} diff --git a/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/SepLocationConformanceTest.java b/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/SepLocationConformanceTest.java new file mode 100644 index 000000000..51dbb6552 --- /dev/null +++ b/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/SepLocationConformanceTest.java @@ -0,0 +1,104 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Stream; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +/** + * Runs the reference location test suite for AWS shared configuration file path resolution. + */ +class SepLocationConformanceTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @TestFactory + Stream locationTests() throws IOException { + JsonNode root; + try (InputStream is = getClass().getResourceAsStream("config-file-location-tests.json")) { + assertNotNull(is, "config-file-location-tests.json not found on classpath"); + root = MAPPER.readTree(is); + } + + JsonNode tests = root.get("tests"); + assertNotNull(tests, "No 'tests' array in JSON"); + + List dynamicTests = new ArrayList<>(); + for (JsonNode test : tests) { + String name = test.has("name") ? test.get("name").asText() : "unnamed"; + dynamicTests.add(DynamicTest.dynamicTest(name, () -> runTest(test))); + } + return dynamicTests.stream(); + } + + private void runTest(JsonNode test) { + Map env = new HashMap<>(); + if (test.has("environment")) { + Iterator> fields = test.get("environment").properties().iterator(); + while (fields.hasNext()) { + Map.Entry entry = fields.next(); + env.put(entry.getKey(), entry.getValue().asText()); + } + } + + String platform = test.has("platform") ? test.get("platform").asText() : null; + String languageSpecificHome = test.has("languageSpecificHome") + ? test.get("languageSpecificHome").asText() + : null; + + // Build property getter that simulates os.name and user.home. + Function propertyGetter = key -> { + if ("os.name".equals(key)) { + if ("windows".equals(platform)) { + return "Windows 10"; + } else if ("linux".equals(platform)) { + return "Linux"; + } + return null; + } + if ("user.home".equals(key)) { + return "ignored".equals(languageSpecificHome) ? null : languageSpecificHome; + } + return null; + }; + + Function envGetter = key -> { + String val = env.get(key); + return "ignored".equals(val) ? null : val; + }; + + String expectedConfig = test.get("configLocation").asText(); + String expectedCreds = test.get("credentialsLocation").asText(); + + AwsProfileFile.ResolvedPaths paths = AwsProfileFile.resolveDefaultPaths(envGetter, propertyGetter); + + // Normalize separators for cross-platform comparison. + assertEquals(normalize(expectedConfig), + normalize(paths.configLocation().toString()), + "Config location mismatch"); + assertEquals(normalize(expectedCreds), + normalize(paths.credentialsLocation().toString()), + "Credentials location mismatch"); + } + + private static String normalize(String path) { + return path.replace('\\', '/'); + } +} diff --git a/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/SepParserConformanceTest.java b/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/SepParserConformanceTest.java new file mode 100644 index 000000000..0504b7812 --- /dev/null +++ b/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/SepParserConformanceTest.java @@ -0,0 +1,234 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; +import org.junit.jupiter.api.io.TempDir; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +/** + * Runs the reference test suite from config-file-parser-tests.json. + */ +class SepParserConformanceTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + @TempDir + Path tmp; + + @TestFactory + Stream conformanceTests() throws IOException { + JsonNode root; + try (InputStream is = getClass().getResourceAsStream("config-file-parser-tests.json")) { + assertNotNull(is, "config-file-parser-tests.json not found on classpath"); + root = MAPPER.readTree(is); + } + + JsonNode tests = root.get("tests"); + assertNotNull(tests, "No 'tests' array in JSON"); + + List dynamicTests = new ArrayList<>(); + for (JsonNode test : tests) { + String name = test.has("name") ? test.get("name").asText() : "unnamed"; + dynamicTests.add(DynamicTest.dynamicTest(name, () -> runTest(test))); + } + return dynamicTests.stream(); + } + + private void runTest(JsonNode test) throws IOException { + JsonNode input = test.get("input"); + JsonNode output = test.get("output"); + + String configContent = input.has("configFile") ? input.get("configFile").asText() : null; + String credentialsContent = input.has("credentialsFile") ? input.get("credentialsFile").asText() : null; + + Path configPath = null; + Path credentialsPath = null; + if (configContent != null) { + configPath = tmp.resolve("config-" + System.nanoTime()); + Files.writeString(configPath, configContent, StandardCharsets.UTF_8); + } + if (credentialsContent != null) { + credentialsPath = tmp.resolve("credentials-" + System.nanoTime()); + Files.writeString(credentialsPath, credentialsContent, StandardCharsets.UTF_8); + } + + if (output.has("errorContaining")) { + String expectedError = output.get("errorContaining").asText(); + try { + buildFile(configPath, credentialsPath); + fail("Expected an error containing: " + expectedError); + } catch (ConfigFileParseException e) { + assertTrue(e.getMessage().toLowerCase().contains(expectedError.toLowerCase()), + "Error message '" + e.getMessage() + "' does not contain '" + expectedError + "'"); + } + return; + } + + AwsProfileFile file = buildFile(configPath, credentialsPath); + + if (output.has("profiles")) { + Map> expectedProfiles = parseExpectedProfiles(output.get("profiles")); + assertProfilesMatch(expectedProfiles, file); + } + + if (output.has("ssoSessions")) { + Map> expectedSessions = parseExpectedProfiles(output.get("ssoSessions")); + assertSsoSessionsMatch(expectedSessions, file); + } + } + + private AwsProfileFile buildFile(Path configPath, Path credentialsPath) { + AwsProfileFile.Builder builder = AwsProfileFile.builder(); + if (configPath != null) { + builder.configFile(configPath); + } else { + builder.configFile(null); + } + if (credentialsPath != null) { + builder.credentialsFile(credentialsPath); + } else { + builder.credentialsFile(null); + } + return builder.build(); + } + + private Map> parseExpectedProfiles(JsonNode profilesNode) { + Map> result = new LinkedHashMap<>(); + Iterator> fields = profilesNode.properties().iterator(); + while (fields.hasNext()) { + Map.Entry entry = fields.next(); + String profileName = entry.getKey(); + JsonNode propsNode = entry.getValue(); + Map props = new LinkedHashMap<>(); + Iterator> propFields = propsNode.properties().iterator(); + while (propFields.hasNext()) { + Map.Entry propEntry = propFields.next(); + String key = propEntry.getKey(); + JsonNode val = propEntry.getValue(); + if (val.isObject()) { + // Sub-property + Map subProps = new LinkedHashMap<>(); + Iterator> subFields = val.properties().iterator(); + while (subFields.hasNext()) { + Map.Entry subEntry = subFields.next(); + subProps.put(subEntry.getKey(), subEntry.getValue().asText()); + } + props.put(key, subProps); + } else { + props.put(key, val.asText()); + } + } + result.put(profileName, props); + } + return result; + } + + private void assertProfilesMatch(Map> expected, AwsProfileFile file) { + // Check profile names match. + assertEquals(expected.keySet(), + profileNameSet(file), + "Profile names mismatch"); + + for (Map.Entry> e : expected.entrySet()) { + String name = e.getKey(); + AwsProfile profile = file.profile(name); + assertNotNull(profile, "Profile '" + name + "' not found"); + Map expectedProps = e.getValue(); + for (Map.Entry pe : expectedProps.entrySet()) { + String key = pe.getKey(); + Object expectedValue = pe.getValue(); + if (expectedValue instanceof Map) { + @SuppressWarnings("unchecked") + Map expectedSubs = (Map) expectedValue; + Map actualSubs = profile.subProperties(key); + assertNotNull(actualSubs, "Sub-properties for '" + key + "' not found in profile '" + name + "'"); + assertEquals(expectedSubs, + actualSubs, + "Sub-properties mismatch for '" + key + "' in profile '" + name + "'"); + } else { + String actual = profile.property(key); + assertEquals((String) expectedValue, + actual, + "Property '" + key + "' mismatch in profile '" + name + "'"); + } + } + // Verify no extra properties. + assertEquals(expectedProps.size(), + countProperties(profile, expectedProps), + "Extra properties in profile '" + name + "'"); + } + } + + private void assertSsoSessionsMatch(Map> expected, AwsProfileFile file) { + Map actualSessions = file.ssoSessions(); + assertEquals(expected.keySet(), actualSessions.keySet(), "SSO session names mismatch"); + for (Map.Entry> e : expected.entrySet()) { + String name = e.getKey(); + AwsProfile actual = actualSessions.get(name); + assertNotNull(actual, "SSO session '" + name + "' not found"); + Map expectedProps = e.getValue(); + for (Map.Entry pe : expectedProps.entrySet()) { + String key = pe.getKey(); + Object expectedValue = pe.getValue(); + if (expectedValue instanceof Map) { + @SuppressWarnings("unchecked") + Map expectedSubs = (Map) expectedValue; + Map actualSubs = actual.subProperties(key); + assertNotNull(actualSubs, + "SSO session '" + name + "' sub-properties for '" + key + "' not found"); + assertEquals(expectedSubs, + actualSubs, + "SSO session '" + name + "' property '" + key + "' mismatch"); + } else { + assertEquals(expectedValue, + actual.property(key), + "SSO session '" + name + "' property '" + key + "' mismatch"); + } + } + } + } + + private static java.util.Set profileNameSet(AwsProfileFile file) { + java.util.Set names = new java.util.LinkedHashSet<>(); + for (AwsProfile p : file.profiles()) { + names.add(p.name()); + } + return names; + } + + private static int countProperties(AwsProfile profile, Map expected) { + int count = 0; + for (Map.Entry e : profile.properties().entrySet()) { + if (expected.containsKey(e.getKey())) { + count++; + } + } + for (Map.Entry> e : profile.subProperties().entrySet()) { + if (expected.containsKey(e.getKey())) { + count++; + } + } + return count; + } +} diff --git a/aws/aws-config/src/test/resources/software/amazon/smithy/java/aws/config/config-file-location-tests.json b/aws/aws-config/src/test/resources/software/amazon/smithy/java/aws/config/config-file-location-tests.json new file mode 100644 index 000000000..2029792d8 --- /dev/null +++ b/aws/aws-config/src/test/resources/software/amazon/smithy/java/aws/config/config-file-location-tests.json @@ -0,0 +1,135 @@ +{ + "description": "These are test descriptions that specify which files and profiles should be loaded based on the specified environment variables.", + + "tests": [ + { + "name": "User home is loaded from $HOME with highest priority on non-windows platforms.", + "environment": { + "HOME": "/home/user", + "USERPROFILE": "ignored", + "HOMEDRIVE": "ignored", + "HOMEPATH": "ignored" + }, + "languageSpecificHome": "ignored", + "platform": "linux", + "profile": "default", + "configLocation": "/home/user/.aws/config", + "credentialsLocation": "/home/user/.aws/credentials" + }, + + { + "name": "User home is loaded using language-specific resolution on non-windows platforms when $HOME is not set.", + "environment": { + "USERPROFILE": "ignored", + "HOMEDRIVE": "ignored", + "HOMEPATH": "ignored" + }, + "languageSpecificHome": "/home/user", + "platform": "linux", + "profile": "default", + "configLocation": "/home/user/.aws/config", + "credentialsLocation": "/home/user/.aws/credentials" + }, + + { + "name": "User home is loaded from $HOME with highest priority on windows platforms.", + "environment": { + "HOME": "C:\\users\\user", + "USERPROFILE": "ignored", + "HOMEDRIVE": "ignored", + "HOMEPATH": "ignored" + }, + "languageSpecificHome": "ignored", + "platform": "windows", + "profile": "default", + "configLocation": "C:\\users\\user\\.aws\\config", + "credentialsLocation": "C:\\users\\user\\.aws\\credentials" + }, + + { + "name": "User home is loaded from $USERPROFILE on windows platforms when $HOME is not set.", + "environment": { + "USERPROFILE": "C:\\users\\user", + "HOMEDRIVE": "ignored", + "HOMEPATH": "ignored" + }, + "languageSpecificHome": "ignored", + "platform": "windows", + "profile": "default", + "configLocation": "C:\\users\\user\\.aws\\config", + "credentialsLocation": "C:\\users\\user\\.aws\\credentials" + }, + + { + "name": "User home is loaded from $HOMEDRIVE$HOMEPATH on windows platforms when $HOME and $USERPROFILE are not set.", + "environment": { + "HOMEDRIVE": "C:", + "HOMEPATH": "\\users\\user" + }, + "languageSpecificHome": "ignored", + "platform": "windows", + "profile": "default", + "configLocation": "C:\\users\\user\\.aws\\config", + "credentialsLocation": "C:\\users\\user\\.aws\\credentials" + }, + + { + "name": "User home is loaded using language-specific resolution on windows platforms when no environment variables are set.", + "environment": { + }, + "languageSpecificHome": "C:\\users\\user", + "platform": "windows", + "profile": "default", + "configLocation": "C:\\users\\user\\.aws\\config", + "credentialsLocation": "C:\\users\\user\\.aws\\credentials" + }, + + { + "name": "The default config location can be overridden by the user on non-windows platforms.", + "environment": { + "AWS_CONFIG_FILE": "/other/path/config", + "HOME": "/home/user" + }, + "platform": "linux", + "profile": "default", + "configLocation": "/other/path/config", + "credentialsLocation": "/home/user/.aws/credentials" + }, + + { + "name": "The default credentials location can be overridden by the user on non-windows platforms.", + "environment": { + "AWS_SHARED_CREDENTIALS_FILE": "/other/path/credentials", + "HOME": "/home/user" + }, + "platform": "linux", + "profile": "default", + "configLocation": "/home/user/.aws/config", + "credentialsLocation": "/other/path/credentials" + }, + + { + "name": "The default credentials location can be overridden by the user on windows platforms.", + "environment": { + "AWS_CONFIG_FILE": "C:\\other\\path\\config", + "HOME": "C:\\users\\user" + }, + "platform": "windows", + "profile": "default", + "configLocation": "C:\\other\\path\\config", + "credentialsLocation": "C:\\users\\user\\.aws\\credentials" + }, + + { + "name": "The default credentials location can be overridden by the user on windows platforms.", + "environment": { + "AWS_SHARED_CREDENTIALS_FILE": "C:\\other\\path\\credentials", + "HOME": "C:\\users\\user" + }, + "platform": "windows", + "profile": "default", + "configLocation": "C:\\users\\user\\.aws\\config", + "credentialsLocation": "C:\\other\\path\\credentials" + } + ] +} \ No newline at end of file diff --git a/aws/aws-config/src/test/resources/software/amazon/smithy/java/aws/config/config-file-parser-tests.json b/aws/aws-config/src/test/resources/software/amazon/smithy/java/aws/config/config-file-parser-tests.json new file mode 100644 index 000000000..7bada6746 --- /dev/null +++ b/aws/aws-config/src/test/resources/software/amazon/smithy/java/aws/config/config-file-parser-tests.json @@ -0,0 +1,1572 @@ +{ + "description": "These are test descriptions that describe how to convert a raw configuration and credentials file into an in-memory representation of the config file for profiles and sso-sessions.", + + "tests": [ + { + "name": "Empty files have no profiles.", + "input": { + "configFile" : "" + }, + "output": { + "profiles": {} + } + }, + + { + "name": "Empty profiles have no properties.", + "input": { + "configFile": "[profile foo]\n" + }, + "output": { + "profiles": { + "foo": {} + } + } + }, + + { + "name": "Profile definitions must end with brackets.", + "input": { + "configFile": "[profile foo" + }, + "output": { + "errorContaining": "Section definition must end with ']'" + } + }, + + { + "name": "Profile names should be trimmed.", + "input": { + "configFile": "[profile \tfoo \t]" + }, + "output": { + "profiles": { + "foo": {} + } + } + }, + + { + "name": "Tabs can separate profile names from the section.", + "input": { + "configFile": "[profile\tfoo]" + }, + "output": { + "profiles": { + "foo": {} + } + } + }, + + { + "name": "Properties must be defined in a section.", + "input": { + "configFile": "name = value" + }, + "output": { + "errorContaining": "Expected a section definition" + } + }, + + { + "name": "Profiles can contain properties.", + "input": { + "configFile": "[profile foo]\nname = value" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Windows style line endings are supported.", + "input": { + "configFile": "[profile foo]\r\nname = value" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Equals signs are supported in property values.", + "input": { + "configFile": "[profile foo]\nname = val=ue" + }, + "output": { + "profiles": { + "foo": { + "name": "val=ue" + } + } + } + }, + + { + "name": "Unicode characters are supported in property values.", + "input": { + "configFile": "[profile foo]\nname = 😂" + }, + "output": { + "profiles": { + "foo": { + "name": "😂" + } + } + } + }, + + { + "name": "Profiles can contain multiple properties.", + "input": { + "configFile": "[profile foo]\nname = value\nname2 = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value", + "name2": "value2" + } + } + } + }, + + { + "name": "Property keys and values are trimmed.", + "input": { + "configFile": "[profile foo]\nname \t= \tvalue \t" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Property values can be empty.", + "input": { + "configFile": "[profile foo]\nname =" + }, + "output": { + "profiles": { + "foo": { + "name": "" + } + } + } + }, + + { + "name": "Property key cannot be empty.", + "input": { + "configFile": "[profile foo]\n= value" + }, + "output": { + "errorContaining": "Property did not have a name" + } + }, + + { + "name": "Property definitions must contain an equals sign.", + "input": { + "configFile": "[profile foo]\nkey : value" + }, + "output": { + "errorContaining": "Expected an '=' sign defining a property" + } + }, + + { + "name": "Multiple profiles can be empty.", + "input": { + "configFile": "[profile foo]\n[profile bar]" + }, + "output": { + "profiles": { + "foo": {}, + "bar": {} + } + } + }, + + { + "name": "Multiple profiles can have properties.", + "input": { + "configFile": "[profile foo]\nname = value\n[profile bar]\nname2 = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + }, + "bar": { + "name2": "value2" + } + } + } + }, + + { + "name": "Blank lines are ignored.", + "input": { + "configFile": "\t \n[profile foo]\n\t\n \nname = value\n\t \n[profile bar]\n \t" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + }, + "bar": {} + } + } + }, + + { + "name": "Pound sign comments are ignored.", + "input": { + "configFile": "# Comment\n[profile foo] # Comment\nname = value # Comment with # sign" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Semicolon sign comments are ignored.", + "input": { + "configFile": "; Comment\n[profile foo] ; Comment\nname = value ; Comment with ; sign" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + { + "name": "All comment types can be used together.", + "input": { + "configFile": "# Comment\n[profile foo] ; Comment\nname = value # Comment with ; sign" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Comments can be empty.", + "input": { + "configFile": ";\n[profile foo];\nname = value ;\n" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Comments can be adjacent to profile names.", + "input": { + "configFile": "[profile foo]; Adjacent semicolons\n[profile bar]# Adjacent pound signs" + }, + "output": { + "profiles": { + "foo": {}, + "bar": {} + } + } + }, + + { + "name": "Comments adjacent to values are included in the value.", + "input": { + "configFile": "[profile foo]\nname = value; Adjacent semicolons\nname2 = value# Adjacent pound signs" + }, + "output": { + "profiles": { + "foo": { + "name": "value; Adjacent semicolons", + "name2": "value# Adjacent pound signs" + } + } + } + }, + + { + "name": "Property values can be continued on the next line.", + "input": { + "configFile": "[profile foo]\nname = value\n -continued" + }, + "output": { + "profiles": { + "foo": { + "name": "value\n-continued" + } + } + } + }, + + { + "name": "Property values can be continued with multiple lines.", + "input": { + "configFile": "[profile foo]\nname = value\n -continued\n -and-continued" + }, + "output": { + "profiles": { + "foo": { + "name": "value\n-continued\n-and-continued" + } + } + } + }, + + { + "name": "Continuations are trimmed.", + "input": { + "configFile": "[profile foo]\nname = value\n \t -continued \t " + }, + "output": { + "profiles": { + "foo": { + "name": "value\n-continued" + } + } + } + }, + + { + "name": "Continuation values include pound comments.", + "input": { + "configFile": "[profile foo]\nname = value\n -continued # Comment" + }, + "output": { + "profiles": { + "foo": { + "name": "value\n-continued # Comment" + } + } + } + }, + + { + "name": "Continuation values include semicolon comments.", + "input": { + "configFile": "[profile foo]\nname = value\n -continued ; Comment" + }, + "output": { + "profiles": { + "foo": { + "name": "value\n-continued ; Comment" + } + } + } + }, + + { + "name": "Continuations cannot be used outside of a profile.", + "input": { + "configFile": " -continued" + }, + "output": { + "errorContaining": "Expected a section definition" + } + }, + + { + "name": "Continuations cannot be used outside of a property.", + "input": { + "configFile": "[profile foo]\n -continued" + }, + "output": { + "errorContaining": "Expected a property definition" + } + }, + + { + "name": "Continuations reset with profile definitions.", + "input": { + "configFile": "[profile foo]\nname = value\n[profile foo]\n -continued" + }, + "output": { + "errorContaining": "Expected a property definition" + } + }, + + { + "name": "Duplicate profiles in the same file merge properties.", + "input": { + "configFile": "[profile foo]\nname = value\n[profile foo]\nname2 = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value", + "name2": "value2" + } + } + } + }, + + { + "name": "Duplicate properties in a profile use the last one defined.", + "input": { + "configFile": "[profile foo]\nname = value\nname = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "Duplicate properties in duplicate profiles use the last one defined.", + "input": { + "configFile": "[profile foo]\nname = value\n[profile foo]\nname = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "Default profile with profile prefix overrides default profile without prefix when profile prefix is first.", + "input": { + "configFile": "[profile default]\nname = value\n[default]\nname2 = value2" + }, + "output": { + "profiles": { + "default": { + "name": "value" + } + } + } + }, + + { + "name": "Default profile with profile prefix overrides default profile without prefix when profile prefix is last.", + "input": { + "configFile": "[default]\nname2 = value2\n[profile default]\nname = value" + }, + "output": { + "profiles": { + "default": { + "name": "value" + } + } + } + }, + + { + "name": "Invalid profile names are ignored.", + "input": { + "configFile": "[profile in valid]\nname = value", + "credentialsFile": "[in valid 2]\nname2 = value2" + }, + "output": { + "profiles": {} + } + }, + + { + "name": "Invalid property names are ignored.", + "input": { + "configFile": "[profile foo]\nin valid = value" + }, + "output": { + "profiles": { + "foo": {} + } + } + }, + + { + "name": "All valid identifier characters are supported.", + "input": { + "configFile": "[profile ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+]" + }, + "output": { + "profiles": { + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+": {} + } + } + }, + + { + "name": "All valid property name characters are supported.", + "input": { + "configFile": "[profile foo]\nABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+ = value" + }, + "output": { + "profiles": { + "foo": { + "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+": "value" + } + } + } + }, + + { + "name": "Properties can have sub-properties.", + "input": { + "configFile": "[profile foo]\ns3 =\n name = value" + }, + "output": { + "profiles": { + "foo": { + "s3": { + "name": "value" + } + } + } + } + }, + + { + "name": "Invalid sub-property definitions cause an error.", + "input": { + "configFile": "[profile foo]\ns3 =\n invalid" + }, + "output": { + "errorContaining": "Expected an '=' sign defining a property in sub-property" + } + }, + + { + "name": "Sub-property definitions can have an empty value.", + "input": { + "configFile": "[profile foo]\ns3 =\n name =" + }, + "output": { + "profiles": { + "foo": { + "s3": { + "name": "" + } + } + } + } + }, + + { + "name": "Sub property definitions have pound comments applied to the value.", + "input": { + "configFile": "[profile foo]\ns3 =\n name = value # Comment" + }, + "output": { + "profiles": { + "foo": { + "s3": { + "name": "value # Comment" + } + } + } + } + }, + + { + "name": "Sub property definitions have semicolon comments applied to the value.", + "input": { + "configFile": "[profile foo]\ns3 =\n name = value ; Comment" + }, + "output": { + "profiles": { + "foo": { + "s3": { + "name": "value ; Comment" + } + } + } + } + }, + + { + "name": "Sub-property definitions cannot have an empty name.", + "input": { + "configFile": "[profile foo]\ns3 =\n = value" + }, + "output": { + "errorContaining": "Property did not have a name in sub-property" + } + }, + + { + "name": "Sub-property definitions cannot have an invalid name.", + "input": { + "configFile": "[profile foo]\ns3 =\n in valid = value" + }, + "output": { + "profiles": { + "foo": { + "s3": {} + } + } + } + }, + + { + "name": "Sub-properties can have blank lines that are ignored", + "input": { + "configFile": "[profile foo]\ns3 =\n name = value\n\t \n name2 = value2" + }, + "output": { + "profiles": { + "foo": { + "s3": { + "name": "value", + "name2": "value2" + } + } + } + } + }, + + { + "name": "Profiles duplicated in multiple files are merged.", + "input": { + "configFile": "[profile foo]\nname = value", + "credentialsFile": "[foo]\nname2 = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value", + "name2": "value2" + } + } + } + }, + + { + "name": "Default profiles with mixed prefixes in the config file ignore the one without prefix when merging.", + "input": { + "configFile": "[profile default]\nname = value\n[default]\nname2 = value2\n[profile default]\nname3 = value3" + }, + "output": { + "profiles": { + "default": { + "name": "value", + "name3": "value3" + } + } + } + }, + + { + "name": "Default profiles with mixed prefixes merge with credentials", + "input": { + "configFile": "[profile default]\nname = value\n[default]\nname2 = value2\n[profile default]\nname3 = value3", + "credentialsFile": "[default]\nsecret=foo" + }, + "output": { + "profiles": { + "default": { + "name": "value", + "name3": "value3", + "secret": "foo" + } + } + } + }, + + { + "name": "Duplicate properties between files uses credentials property.", + "input": { + "configFile": "[profile foo]\nname = value", + "credentialsFile": "[foo]\nname = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "Config profiles without prefix are ignored.", + "input": { + "configFile": "[foo]\nname = value" + }, + "output": { + "profiles": {} + } + }, + + { + "name": "Credentials profiles with prefix are ignored.", + "input": { + "credentialsFile": "[profile foo]\nname = value" + }, + "output": { + "profiles": {} + } + }, + + { + "name": "Comment characters adjacent to profile decls", + "input": { + "configFile": "[profile foo]; semicolon\n[profile bar]# pound" + }, + "output": { + "profiles": { + "foo": {}, + "bar": {} + } + } + }, + + { + "name": "Invalid continuation", + "input": { + "configFile": "[profile foo]\nname = value\n[profile foo]\n -continued" + }, + "output": { + "errorContaining": "Expected a property definition, found continuation" + } + }, + + { + "name": "profile name with no space after `profile` is invalid", + "input": { + "configFile": "[profilefoo]\nname = value\n[profile bar]" + }, + "output": { + "profiles": { + "bar": {} + } + } + }, + + { + "name": "profile name with extra whitespace", + "input": { + "configFile": "[ profile foo ]\nname = value\n[profile bar]" + }, + "output": { + "profiles": { + "bar": {}, + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "profile name with extra whitespace in credentials", + "input": { + "credentialsFile": "[ foo ]\nname = value\n[profile bar]" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "properties from an invalid profile name are ignored", + "input": { + "configFile": "[profile foo]\nname = value\n[profile in valid]\nx = 1\n[profile bar]\nname = value2" + }, + "output": { + "profiles": { + "bar": { + "name": "value2" + }, + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Duplicate properties in duplicate profiles use the last one defined (case insensitive).", + "input": { + "configFile": "[profile foo]\nName = value\n[profile foo]\nname = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "Empty files have no sso sessions.", + "input": { + "configFile": "" + }, + "output": { + "ssoSessions": {} + } + }, + + { + "name": "Empty sso sessions have no properties.", + "input": { + "configFile": "[sso-session foo]\n" + }, + "output": { + "ssoSessions": { + "foo": {} + } + } + }, + + { + "name": "sso-sessions without a name are ignored.", + "input": { + "configFile": "[sso-session]\nname = value" + }, + "output": { + "ssoSessions": {} + } + }, + + { + "name": "sso-session definitions must end with brackets.", + "input": { + "configFile": "[sso-session foo" + }, + "output": { + "errorContaining": "Section definition must end with ']'" + } + }, + + { + "name": "sso-session names should be trimmed.", + "input": { + "configFile": "[sso-session \tfoo \t]" + }, + "output": { + "ssoSessions": { + "foo": {} + } + } + }, + + { + "name": "Tabs can separate sso-session names from the section.", + "input": { + "configFile": "[sso-session\tfoo]" + }, + "output": { + "ssoSessions": { + "foo": {} + } + } + }, + + { + "name": "sso-sessions can contain properties.", + "input": { + "configFile": "[sso-session foo]\nname = value" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Windows style line endings are supported.", + "input": { + "configFile": "[sso-session foo]\r\nname = value" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Equals signs are supported in property values.", + "input": { + "configFile": "[sso-session foo]\nname = val=ue" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "val=ue" + } + } + } + }, + + { + "name": "Unicode characters are supported in property values.", + "input": { + "configFile": "[sso-session foo]\nname = 😂" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "😂" + } + } + } + }, + + { + "name": "sso-sessions can contain multiple properties.", + "input": { + "configFile": "[sso-session foo]\nname = value\nname2 = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value", + "name2": "value2" + } + } + } + }, + + { + "name": "Property keys and values are trimmed.", + "input": { + "configFile": "[sso-session foo]\nname \t= \tvalue \t" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Property values can be empty.", + "input": { + "configFile": "[sso-session foo]\nname =" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "" + } + } + } + }, + + { + "name": "Property key cannot be empty.", + "input": { + "configFile": "[sso-session foo]\n= value" + }, + "output": { + "errorContaining": "Property did not have a name" + } + }, + + { + "name": "Property definitions must contain an equals sign.", + "input": { + "configFile": "[sso-session foo]\nkey : value" + }, + "output": { + "errorContaining": "Expected an '=' sign defining a property" + } + }, + + { + "name": "Multiple sso-sessions can be empty.", + "input": { + "configFile": "[sso-session foo]\n[sso-session bar]" + }, + "output": { + "ssoSessions": { + "foo": {}, + "bar": {} + } + } + }, + + { + "name": "Multiple sso-sessions can have properties.", + "input": { + "configFile": "[sso-session foo]\nname = value\n[sso-session bar]\nname2 = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + }, + "bar": { + "name2": "value2" + } + } + } + }, + + { + "name": "Blank lines are ignored.", + "input": { + "configFile": "\t \n[sso-session foo]\n\t\n \nname = value\n\t \n[sso-session bar]\n \t" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + }, + "bar": {} + } + } + }, + + { + "name": "Pound sign comments are ignored.", + "input": { + "configFile": "# Comment\n[sso-session foo] # Comment\nname = value # Comment with # sign" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Semicolon sign comments are ignored.", + "input": { + "configFile": "; Comment\n[sso-session foo] ; Comment\nname = value ; Comment with ; sign" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "All comment types can be used together.", + "input": { + "configFile": "# Comment\n[sso-session foo] ; Comment\nname = value # Comment with ; sign" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Comments can be empty.", + "input": { + "configFile": ";\n[sso-session foo];\nname = value ;\n" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Comments can be adjacent to sso-session names.", + "input": { + "configFile": "[sso-session foo]; Adjacent semicolons\n[sso-session bar]# Adjacent pound signs" + }, + "output": { + "ssoSessions": { + "foo": {}, + "bar": {} + } + } + }, + + { + "name": "Comments adjacent to values are included in the value.", + "input": { + "configFile": "[sso-session foo]\nname = value; Adjacent semicolons\nname2 = value# Adjacent pound signs" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value; Adjacent semicolons", + "name2": "value# Adjacent pound signs" + } + } + } + }, + + { + "name": "Property values can be continued on the next line.", + "input": { + "configFile": "[sso-session foo]\nname = value\n -continued" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value\n-continued" + } + } + } + }, + + { + "name": "Property values can be continued with multiple lines.", + "input": { + "configFile": "[sso-session foo]\nname = value\n -continued\n -and-continued" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value\n-continued\n-and-continued" + } + } + } + }, + + { + "name": "Continuations are trimmed.", + "input": { + "configFile": "[sso-session foo]\nname = value\n \t -continued \t " + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value\n-continued" + } + } + } + }, + + { + "name": "Continuation values include pound comments.", + "input": { + "configFile": "[sso-session foo]\nname = value\n -continued # Comment" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value\n-continued # Comment" + } + } + } + }, + + { + "name": "Continuation values include semicolon comments.", + "input": { + "configFile": "[sso-session foo]\nname = value\n -continued ; Comment" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value\n-continued ; Comment" + } + } + } + }, + + { + "name": "Continuations cannot be used outside of a sso-session.", + "input": { + "configFile": " -continued" + }, + "output": { + "errorContaining": "Expected a section definition" + } + }, + + { + "name": "Continuations cannot be used outside of a property.", + "input": { + "configFile": "[sso-session foo]\n -continued" + }, + "output": { + "errorContaining": "Expected a property definition" + } + }, + + { + "name": "Continuations reset with sso-session definitions.", + "input": { + "configFile": "[sso-session foo]\nname = value\n[sso-session foo]\n -continued" + }, + "output": { + "errorContaining": "Expected a property definition" + } + }, + + { + "name": "Duplicate sso-sessions in the same file merge properties.", + "input": { + "configFile": "[sso-session foo]\nname = value\n[sso-session foo]\nname2 = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value", + "name2": "value2" + } + } + } + }, + + { + "name": "Duplicate properties in an sso-session use the last one defined.", + "input": { + "configFile": "[sso-session foo]\nname = value\nname = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "Duplicate properties in duplicate sso-sessions use the last one defined.", + "input": { + "configFile": "[sso-session foo]\nname = value\n[sso-session foo]\nname = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "Invalid sso-session names are ignored.", + "input": { + "configFile": "[sso-session in valid]\nname = value" + }, + "output": { + "ssoSessions": {} + } + }, + + { + "name": "Invalid property names are ignored.", + "input": { + "configFile": "[sso-session foo]\nin valid = value" + }, + "output": { + "ssoSessions": { + "foo": {} + } + } + }, + + { + "name": "All valid identifier characters are supported.", + "input": { + "configFile": "[sso-session ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+]" + }, + "output": { + "ssoSessions": { + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+": {} + } + } + }, + + { + "name": "All valid property name characters are supported.", + "input": { + "configFile": "[sso-session foo]\nABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+ = value" + }, + "output": { + "ssoSessions": { + "foo": { + "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+": "value" + } + } + } + }, + + { + "name": "Properties can have sub-properties.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n name = value" + }, + "output": { + "ssoSessions": { + "foo": { + "s3": { + "name": "value" + } + } + } + } + }, + + { + "name": "Invalid sub-property definitions cause an error.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n invalid" + }, + "output": { + "errorContaining": "Expected an '=' sign defining a property in sub-property" + } + }, + + { + "name": "Sub-property definitions can have an empty value.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n name =" + }, + "output": { + "ssoSessions": { + "foo": { + "s3": { + "name": "" + } + } + } + } + }, + + { + "name": "Sub property definitions have pound comments applied to the value.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n name = value # Comment" + }, + "output": { + "ssoSessions": { + "foo": { + "s3": { + "name": "value # Comment" + } + } + } + } + }, + + { + "name": "Sub property definitions have semicolon comments applied to the value.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n name = value ; Comment" + }, + "output": { + "ssoSessions": { + "foo": { + "s3": { + "name": "value ; Comment" + } + } + } + } + }, + + { + "name": "Sub-property definitions cannot have an empty name.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n = value" + }, + "output": { + "errorContaining": "Property did not have a name in sub-property" + } + }, + + { + "name": "Sub-property definitions cannot have an invalid name.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n in valid = value" + }, + "output": { + "ssoSessions": { + "foo": { + "s3": {} + } + } + } + }, + + { + "name": "Sub-properties can have blank lines that are ignored", + "input": { + "configFile": "[sso-session foo]\ns3 =\n name = value\n\t \n name2 = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "s3": { + "name": "value", + "name2": "value2" + } + } + } + } + }, + + { + "name": "Config profiles without prefix are ignored.", + "input": { + "configFile": "[foo]\nname = value" + }, + "output": { + "ssoSessions": {} + } + }, + + { + "name": "Comment characters adjacent to sso-session decls", + "input": { + "configFile": "[sso-session foo]; semicolon\n[sso-session bar]# pound" + }, + "output": { + "ssoSessions": { + "foo": {}, + "bar": {} + } + } + }, + + { + "name": "Invalid continuation", + "input": { + "configFile": "[sso-session foo]\nname = value\n[sso-session foo]\n -continued" + }, + "output": { + "errorContaining": "Expected a property definition, found continuation" + } + }, + + { + "name": "profile name with no space after `sso-session` is invalid", + "input": { + "configFile": "[sso-sessionfoo]\nname = value\n[sso-session bar]" + }, + "output": { + "ssoSessions": { + "bar": {} + } + } + }, + + { + "name": "sso-session name with extra whitespace", + "input": { + "configFile": "[ sso-session foo ]\nname = value\n[sso-session bar]" + }, + "output": { + "ssoSessions": { + "bar": {}, + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "properties from an invalid sso-session name are ignored", + "input": { + "configFile": "[sso-session foo]\nname = value\n[sso-session in valid]\nx = 1\n[sso-session bar]\nname = value2" + }, + "output": { + "ssoSessions": { + "bar": { + "name": "value2" + }, + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Duplicate properties in duplicate sso-sessions use the last one defined (case insensitive).", + "input": { + "configFile": "[sso-session foo]\nName = value\n[sso-session foo]\nname = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "sso-sessions in the credentials file are ignored.", + "input": { + "credentialsFile": "[sso-session foo]\nName = value" + }, + "output": { + "ssoSessions": {} + } + }, + + { + "name": "Profile and sso-session can share names.", + "input": { + "configFile": "[profile foo]\nname = value\n[sso-session foo]\nname = value" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + }, + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + } + + ] +} \ No newline at end of file diff --git a/aws/aws-credential-chain/README.md b/aws/aws-credential-chain/README.md new file mode 100644 index 000000000..e83abd0e4 --- /dev/null +++ b/aws/aws-credential-chain/README.md @@ -0,0 +1,99 @@ +# Credential Chain + +Assembles an ordered identity provider chain from SPI-discovered providers. +Supports any identity type (AWS credentials, bearer tokens, etc.) through a +single generic chain. + +## Dependency + +```kotlin +dependencies { + implementation("software.amazon.smithy.java:aws-credential-chain:1.1.0") +} +``` + +## Wiring to a client + +### Automatic (codegen) + +Services modeled with `@aws.auth#sigv4` or `@aws.auth#sigv4a` automatically get +`AwsCredentialChainPlugin` added as a default plugin during code generation. +No manual wiring needed - just add the provider modules you need to your +runtime dependencies. + +### Manual + +```java +var client = MyClient.builder() + .addPlugin(new AwsCredentialChainPlugin()) + .build(); +``` + +The plugin registers the credential chain as the client's identity resolver and +adds an interceptor that invalidates cached credentials on auth failures +(`ExpiredToken`, `InvalidToken`, `AuthFailure`). + +## Standalone usage + +```java +try (var chain = CredentialChain.create(AwsCredentialsIdentity.class)) { + IdentityResult result = chain.resolveIdentity(context); +} +``` + +## How it works + +The chain discovers `ChainIdentityProvider` implementations via ServiceLoader. +Each provider's `create(Class identityType, ProviderContext context)` is +called with the requested identity type. Providers that don't support the type +return `null` and are skipped. The chain tries remaining providers in order +until one succeeds. + +## Standard slots (in priority order) + +1. `CODE` — programmatic +2. `JAVA_SYSTEM_PROPERTIES` — `aws.accessKeyId` +3. `ENVIRONMENT` — `AWS_ACCESS_KEY_ID` +4. `WEB_IDENTITY_TOKEN_ENV` — `AWS_WEB_IDENTITY_TOKEN_FILE` + `AWS_ROLE_ARN` +5. `SHARED_CONFIG` — `~/.aws/config` / `~/.aws/credentials` +6. `ECS_CONTAINER` — `AWS_CONTAINER_CREDENTIALS_FULL_URI` +7. `EC2_INSTANCE_METADATA` — IMDS + +## Ordering + +Providers position themselves relative to standard slots using +`OrderingConstraint`: + +- `Standard(slot)` — claims a standard slot (one provider per slot) +- `Before(slot)` — inserts before the given slot's position +- `After(slot)` — inserts after the given slot's position + +Before/After reference enum values only, so cycles are impossible. If the +referenced slot has no registered provider, the custom provider is placed where +that slot would be in enum order. + +## Adding a custom provider + +```java +public class MyProvider implements ChainIdentityProvider { + @Override + public String name() { + return "MyCustomProvider"; + } + + @Override + public OrderingConstraint ordering() { + return new OrderingConstraint.After(StandardProvider.SHARED_CONFIG); + } + + @Override + public IdentityResolver create(Class identityType, ProviderContext ctx) { + if (identityType == AwsCredentialsIdentity.class) { + return (IdentityResolver) new MyResolver(); + } + return null; + } +} +``` + +Register in `META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider`. diff --git a/aws/aws-credential-chain/build.gradle.kts b/aws/aws-credential-chain/build.gradle.kts new file mode 100644 index 000000000..acb06177f --- /dev/null +++ b/aws/aws-credential-chain/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + id("smithy-java.module-conventions") +} + +description = "This module provides the AWS credential provider chain with SPI-based provider discovery." + +extra["displayName"] = "Smithy :: Java :: AWS :: Credential Chain" +extra["moduleName"] = "software.amazon.smithy.java.aws.credentials.chain" + +dependencies { + api(project(":aws:aws-auth-api")) + api(project(":auth-api")) + api(project(":aws:aws-config")) + implementation(project(":client:client-core")) + implementation(project(":codecs:json-codec", configuration = "shadow")) + implementation(project(":logging")) + testImplementation("tools.jackson.core:jackson-databind:3.1.2") +} diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainIdentityProvider.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainIdentityProvider.java new file mode 100644 index 000000000..727df886c --- /dev/null +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainIdentityProvider.java @@ -0,0 +1,51 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.chain; + +import java.util.Set; +import software.amazon.smithy.java.auth.api.identity.Identity; + +/** + * SPI for registering an identity provider into a credential/token chain. + * + *

Implementations are discovered via the language's plugin mechanism (e.g., {@code ServiceLoader} + * in Java) and sorted by {@link #ordering()} before {@link #setup} is called. A provider + * registers its resolver by calling {@link ChainSetup#addResolver} or + * {@link ChainSetup#addTerminalResolver} from within {@code create()}. + */ +public interface ChainIdentityProvider { + /** + * @return the unique name of this provider. + */ + String name(); + + /** + * @return the ordering constraint for this provider. + */ + OrderingConstraint ordering(); + + /** + * The business metric feature IDs emitted when this provider successfully resolves an identity. + * + * @return the feature IDs, or empty if none. + */ + default Set featureIds() { + return Set.of(); + } + + /** + * Called once during chain assembly in sorted order. The provider inspects the identity + * type and shared state on the setup, then optionally calls {@link ChainSetup#addResolver} + * or {@link ChainSetup#addTerminalResolver} to register a resolver. + * + *

If this provider's preconditions are not met (wrong identity type, source not + * configured), it simply returns without calling any add method. + * + * @param identityType the identity class the chain is resolving. + * @param setup the chain setup context. + */ + void setup(Class identityType, ChainSetup setup); +} diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainSetup.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainSetup.java new file mode 100644 index 000000000..e8a1f7906 --- /dev/null +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainSetup.java @@ -0,0 +1,264 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.chain; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ScheduledExecutorService; +import java.util.function.Function; +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; +import software.amazon.smithy.java.aws.config.AwsProfile; +import software.amazon.smithy.java.aws.config.AwsProfileFile; +import software.amazon.smithy.java.context.Context; +import software.amazon.smithy.utils.SmithyInternalApi; + +/** + * Mutable assembly context passed to each {@link ChainIdentityProvider#setup} during + * credential chain construction. + * + *

Providers use this to: + *

    + *
  • Read shared state ({@link #profile()}, {@link #profileFile()}, {@link #executor()})
  • + *
  • Write shared state ({@link #setProfileFile(AwsProfileFile)}, {@link #setProfile(AwsProfile)})
  • + *
  • Register resolvers ({@link #addResolver(IdentityResolver)}, {@link #addTerminalResolver(IdentityResolver)})
  • + *
  • Read environment variables ({@link #getenv(String)})
  • + *
+ * + *

When {@link #addTerminalResolver(IdentityResolver)} is called, assembly stops immediately + * and no further providers are invoked. + * + *

This class is not thread-safe. It is used only during the single-threaded assembly phase. + * Resolvers MUST NOT retain a reference to this object. + */ +public final class ChainSetup { + private final ScheduledExecutorService executor; + private final String profileNameOverride; + private final Context properties; + private final List resolvers = new ArrayList<>(); + private final Function envFn; + private AwsProfileFile profileFile; + private AwsProfile profile; + private boolean terminal; + private ChainIdentityProvider currentProvider; + + private ChainSetup(Builder builder) { + this.executor = builder.executor; + this.profileNameOverride = builder.profileNameOverride; + this.properties = Context.create(); + this.envFn = builder.envFn; + } + + /** + * Creates a new builder for {@link ChainSetup}. + * + * @return a new builder. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Returns the shared executor for scheduling background credential refresh tasks. + * + * @return the scheduled executor, or {@code null} if none was configured. + */ + public ScheduledExecutorService executor() { + return executor; + } + + /** + * Returns the client-specified profile name override, or {@code null} to use the + * default resolution order ({@code AWS_PROFILE} env var, {@code aws.profile} system + * property, then {@code "default"}). + * + * @return the profile name override, or {@code null}. + */ + public String profileNameOverride() { + return profileNameOverride; + } + + /** + * Returns the value of the given environment variable, or {@code null} if not set. + * + *

In production this delegates to {@link System#getenv(String)}. In tests, a custom + * function can be provided via {@link Builder#env(Function)} for isolation. + * + * @param name the environment variable name. + * @return the value, or {@code null}. + */ + public String getenv(String name) { + return envFn.apply(name); + } + + /** + * Returns a general-purpose typed property bag for sharing additional state between + * providers during assembly. + * + * @return the shared context properties. + */ + public Context properties() { + return properties; + } + + /** + * Returns the parsed AWS config/credentials file, or {@code null} if not yet loaded. + * + *

Populated by the {@code SHARED_CONFIG} provider during assembly. + * + * @return the parsed profile file, or {@code null}. + */ + public AwsProfileFile profileFile() { + return profileFile; + } + + /** + * Sets the parsed AWS config/credentials file. Called by the {@code SHARED_CONFIG} + * provider during assembly. + * + * @param profileFile the parsed profile file. + */ + public void setProfileFile(AwsProfileFile profileFile) { + this.profileFile = profileFile; + } + + /** + * Returns the active AWS profile, or {@code null} if not yet loaded. + * + *

Populated by the {@code SHARED_CONFIG} provider during assembly. + * + * @return the active profile, or {@code null}. + */ + public AwsProfile profile() { + return profile; + } + + /** + * Sets the active AWS profile. Called by the {@code SHARED_CONFIG} provider during assembly. + * + * @param profile the active profile. + */ + public void setProfile(AwsProfile profile) { + this.profile = profile; + } + + /** + * Registers a resolver at the current provider's position. Assembly continues after + * this call. May be called multiple times to register multiple resolvers that stack + * at this position. + * + * @param resolver the identity resolver to register. + */ + public void addResolver(IdentityResolver resolver) { + resolvers.add(new NamedResolver(currentProvider.name(), currentProvider.featureIds(), resolver)); + } + + /** + * Registers a resolver and stops assembly immediately. No further providers will be + * called. Use when the credential source is authoritative once detected (e.g., + * environment variables contain a complete set of credentials, or a profile explicitly + * configures assume-role). + * + * @param resolver the identity resolver to register. + */ + public void addTerminalResolver(IdentityResolver resolver) { + resolvers.add(new NamedResolver(currentProvider.name(), currentProvider.featureIds(), resolver)); + this.terminal = true; + } + + /** + * Sets the current provider being assembled. Called by the chain before invoking + * each provider's {@link ChainIdentityProvider#setup} method so that + * {@link #addResolver} and {@link #addTerminalResolver} can associate the resolver + * with the correct provider name and feature IDs. + * + *

This method is public to support unit testing of individual providers in + * isolation. Production code should not call this directly. + * + * @param provider the provider currently being assembled. + */ + @SmithyInternalApi + public void setCurrentProvider(ChainIdentityProvider provider) { + this.currentProvider = provider; + } + + /** + * Returns the list of resolvers registered during assembly, in the order they were added. + * + * @return the ordered list of named resolvers. + */ + public List resolvers() { + return resolvers; + } + + boolean isTerminal() { + return terminal; + } + + /** + * A resolver paired with the name and feature IDs of the provider that registered it. + * + * @param name the canonical name of the provider that registered this resolver. + * @param featureIds the feature IDs to emit on successful resolution. + * @param resolver the identity resolver. + */ + public record NamedResolver(String name, Set featureIds, IdentityResolver resolver) {} + + /** + * Builder for {@link ChainSetup}. + */ + public static final class Builder { + private ScheduledExecutorService executor; + private String profileNameOverride; + private Function envFn = System::getenv; + + private Builder() {} + + /** + * Sets the shared executor for background credential refresh. + * + * @param executor the scheduled executor. + * @return this builder. + */ + public Builder executor(ScheduledExecutorService executor) { + this.executor = executor; + return this; + } + + /** + * Sets the profile name override. When set, the {@code SHARED_CONFIG} provider + * uses this name instead of resolving from {@code AWS_PROFILE} or system properties. + * + * @param profileNameOverride the profile name to use. + * @return this builder. + */ + public Builder profileNameOverride(String profileNameOverride) { + this.profileNameOverride = profileNameOverride; + return this; + } + + /** + * Sets the function used to resolve environment variables. Defaults to + * {@link System#getenv(String)}. Override in tests for isolation. + * + * @param envFn the environment variable lookup function. + * @return this builder. + */ + public Builder env(Function envFn) { + this.envFn = envFn; + return this; + } + + /** + * Builds the {@link ChainSetup}. + * + * @return the constructed setup. + */ + public ChainSetup build() { + return new ChainSetup(this); + } + } +} diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialChain.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialChain.java new file mode 100644 index 000000000..e437184c2 --- /dev/null +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialChain.java @@ -0,0 +1,301 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.chain; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.ServiceLoader; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.stream.Collectors; +import software.amazon.smithy.java.auth.api.identity.Identity; +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; +import software.amazon.smithy.java.auth.api.identity.IdentityResult; +import software.amazon.smithy.java.client.core.CallContext; +import software.amazon.smithy.java.context.Context; +import software.amazon.smithy.java.logging.InternalLogger; + +/** + * A credential provider chain. + * + *

Discovers {@link ChainIdentityProvider} implementations via {@link ServiceLoader}, assembles them into an + * ordered chain based on {@link StandardProvider} slots and relative ordering constraints, and resolves + * credentials by trying each provider in order. + * + *

Usage: + *

{@code
+ * var chain = CredentialChain.create();
+ * var result = chain.resolveIdentity(Context.empty());
+ * }
+ * + *

The chain is assembled once at creation time. Providers that are not on the classpath simply don't + * participate: their slots are skipped. If no provider in the chain can resolve credentials, the chain returns an + * error result describing which providers were tried. + */ +public final class CredentialChain implements IdentityResolver, AutoCloseable { + + private static final InternalLogger LOGGER = InternalLogger.getLogger(CredentialChain.class); + + private final Class identityType; + private final List resolvers; + private final ScheduledExecutorService executor; + + private CredentialChain( + Class identityType, + List resolvers, + ScheduledExecutorService executor + ) { + this.identityType = identityType; + this.resolvers = resolvers; + this.executor = executor; + } + + /** + * Create a credential chain by discovering providers via ServiceLoader. + * + * @param identityType Identity type to resolve. + * @return the assembled chain. + * @throws IllegalStateException if two providers claim the same standard slot. + */ + public static CredentialChain create(Class identityType) { + return create(identityType, Executors.newSingleThreadScheduledExecutor(r2 -> { + Thread t = new Thread(r2, "aws-credential-chain-refresh"); + t.setDaemon(true); + return t; + })); + } + + /** + * Create a credential chain by discovering providers via ServiceLoader. + * + * @param identityType Identity type to resolve. + * @param ex Executor used for background resolution. + * @return the assembled chain. + * @throws IllegalStateException if two providers claim the same standard slot. + */ + public static CredentialChain create(Class identityType, ScheduledExecutorService ex) { + List registrations = new ArrayList<>(); + for (ChainIdentityProvider r : ServiceLoader.load(ChainIdentityProvider.class)) { + registrations.add(r); + } + return assemble(identityType, registrations, ex); + } + + static CredentialChain assemble( + Class identityType, + List registrations, + ScheduledExecutorService executor + ) { + // Check for duplicate names. + Set seenNames = new HashSet<>(); + for (ChainIdentityProvider r : registrations) { + if (!seenNames.add(r.name())) { + throw new IllegalStateException("Duplicate credential provider registration name: '" + r.name() + "'"); + } + } + + // Sort providers by ordering constraint (enum order for Standard, relative for Before/After). + List sorted = sortByOrdering(registrations); + + // Call create() on each provider in sorted order. + ChainSetup setup = ChainSetup.builder().executor(executor).build(); + + for (ChainIdentityProvider provider : sorted) { + setup.setCurrentProvider(provider); + provider.setup(identityType, setup); + if (setup.isTerminal()) { + break; + } + } + + var ordered = setup.resolvers(); + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Assembled credential chain: {}", + ordered.stream().map(ChainSetup.NamedResolver::name).collect(Collectors.joining(", "))); + } + + // Warn about detected-but-unclaimed slots. + Set claimed = new HashSet<>(); + for (var nr : ordered) { + for (ChainIdentityProvider p : sorted) { + if (p.name().equals(nr.name()) + && p.ordering() instanceof OrderingConstraint.Standard(StandardProvider s)) { + claimed.add(s); + } + } + } + warnDetectedButUnclaimed(claimed); + return new CredentialChain<>(identityType, Collections.unmodifiableList(ordered), executor); + } + + private static List sortByOrdering(List providers) { + // Separate into standard-slot providers and relative providers. + List standards = new ArrayList<>(); + List befores = new ArrayList<>(); + List afters = new ArrayList<>(); + Set seenSlots = new HashSet<>(); + + for (ChainIdentityProvider p : providers) { + switch (p.ordering()) { + case OrderingConstraint.Standard(StandardProvider slot) -> { + if (!seenSlots.add(slot)) { + throw new IllegalStateException("Two providers claim the same standard slot '" + + slot + "': check provider '" + p.name() + "'"); + } + standards.add(p); + } + case OrderingConstraint.Before b -> befores.add(p); + case OrderingConstraint.After a -> afters.add(p); + } + } + + // Sort standards by enum ordinal. + standards.sort((a, b) -> { + var slotA = ((OrderingConstraint.Standard) a.ordering()).slot(); + var slotB = ((OrderingConstraint.Standard) b.ordering()).slot(); + return slotA.compareTo(slotB); + }); + + // Build final list: insert Before/After relative to their referenced slot's position. + List result = new ArrayList<>(standards); + for (ChainIdentityProvider p : befores) { + var slot = ((OrderingConstraint.Before) p.ordering()).slot(); + int idx = indexOfSlot(result, slot); + result.add(idx, p); + } + for (ChainIdentityProvider p : afters) { + var slot = ((OrderingConstraint.After) p.ordering()).slot(); + int idx = indexOfSlot(result, slot); + int insertAt = Math.min(idx + 1, result.size()); + result.add(insertAt, p); + } + return result; + } + + private static int indexOfSlot(List list, StandardProvider slot) { + for (int i = 0; i < list.size(); i++) { + if (list.get(i).ordering() instanceof OrderingConstraint.Standard(StandardProvider s) && s == slot) { + return i; + } + // If slot not found, find where it would be by enum order. + if (list.get(i).ordering() instanceof OrderingConstraint.Standard(StandardProvider s) + && s.ordinal() > slot.ordinal()) { + return i; + } + } + return list.size(); + } + + private static void warnDetectedButUnclaimed(Set claimed) { + for (StandardProvider slot : StandardProvider.values()) { + if (slot.moduleSuggestion() != null && !claimed.contains(slot) && slot.isDetected()) { + LOGGER.warn("{} credentials detected but no provider is registered for the '{}' slot. " + + "Add '{}' to your dependencies.", + slot.name(), + slot.name(), + slot.moduleSuggestion()); + } + } + } + + @Override + @SuppressWarnings("unchecked") + public IdentityResult resolveIdentity(Context requestProperties) { + if (resolvers.isEmpty()) { + return IdentityResult.ofError(getClass(), + "No credential providers were discovered. Ensure at least one " + + "aws-credentials-* module is on the classpath." + detectedButMissingHints()); + } + + // More cheaply build up a list of failures, and defer string-ing them into a StringBuilder. + List errors = new ArrayList<>(); + + for (var nr : resolvers) { + var result = nr.resolver().resolveIdentity(requestProperties); + if (result.identity() != null) { + if (!nr.featureIds().isEmpty()) { + var ids = requestProperties.get(CallContext.FEATURE_IDS); + if (ids != null) { + ids.addAll(nr.featureIds()); + } + } + return (IdentityResult) result; + } + errors.add(nr.name()); + errors.add(result.error()); + } + + StringBuilder missing = new StringBuilder(); + for (var i = 0; i < errors.size(); i += 2) { + if (i > 0) { + errors.add("; "); + } + missing.append(errors.get(i)).append(": ").append(errors.get(i + 1)); + } + + return IdentityResult.ofError(getClass(), + "Unable to resolve AWS credentials from any provider in the chain. Tried: " + missing + + detectedButMissingHints()); + } + + private String detectedButMissingHints() { + StringBuilder hints = new StringBuilder(); + for (StandardProvider slot : StandardProvider.values()) { + if (slot.moduleSuggestion() != null && slot.isDetected()) { + if (!isClaimed(slot)) { + hints.append(" Detected ") + .append(slot.name()) + .append(" credentials; add '") + .append(slot.moduleSuggestion()) + .append("' to your dependencies."); + } + } + } + return hints.toString(); + } + + private boolean isClaimed(StandardProvider slot) { + for (var nr : resolvers) { + if (nr.name().equals(slot.name().toLowerCase(Locale.ROOT))) { + return true; + } + } + return false; + } + + /** + * @return the ordered list of provider names in this chain. + */ + public List providerNames() { + List names = new ArrayList<>(resolvers.size()); + for (var nr : resolvers) { + names.add(nr.name()); + } + return names; + } + + @Override + public Class identityType() { + return identityType; + } + + @Override + public void invalidate() { + for (var nr : resolvers) { + nr.resolver().invalidate(); + } + } + + @Override + public void close() { + executor.shutdownNow(); + } +} diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialFeatureId.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialFeatureId.java new file mode 100644 index 000000000..f38f578b6 --- /dev/null +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialFeatureId.java @@ -0,0 +1,20 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.chain; + +import software.amazon.smithy.java.client.core.FeatureId; + +/** + * A {@link FeatureId} for credential resolution business metrics. + * + * @param id the short feature ID string (e.g., "n" for profile static credentials). + */ +public record CredentialFeatureId(String id) implements FeatureId { + @Override + public String getShortName() { + return id; + } +} diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/OrderingConstraint.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/OrderingConstraint.java new file mode 100644 index 000000000..284fbdfbf --- /dev/null +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/OrderingConstraint.java @@ -0,0 +1,61 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.chain; + +/** + * Describes where a {@link ChainIdentityProvider} sits in the credential chain. + * + *

Three forms: + *

    + *
  • {@link Standard} — claims a standard slot. At most one provider may claim each slot; + * a conflict at assembly time is a fatal error.
  • + *
  • {@link Before} — positions the provider immediately before a standard slot.
  • + *
  • {@link After} — positions the provider immediately after a standard slot.
  • + *
+ * + *

{@link Before} and {@link After} reference {@link StandardProvider} enum values only, not + * arbitrary provider names. This eliminates the possibility of cycles in ordering constraints. + */ +public sealed interface OrderingConstraint { + /** + * Claims a standard slot in the default chain. Only one provider may claim each slot. + * + * @param slot the standard slot to claim. + */ + record Standard(StandardProvider slot) implements OrderingConstraint { + public Standard { + if (slot == null) { + throw new IllegalArgumentException("slot must not be null"); + } + } + } + + /** + * Positions a provider immediately before the given standard slot. + * + * @param slot the standard slot this provider must come before. + */ + record Before(StandardProvider slot) implements OrderingConstraint { + public Before { + if (slot == null) { + throw new IllegalArgumentException("slot must not be null"); + } + } + } + + /** + * Positions a provider immediately after the given standard slot. + * + * @param slot the standard slot this provider must come after. + */ + record After(StandardProvider slot) implements OrderingConstraint { + public After { + if (slot == null) { + throw new IllegalArgumentException("slot must not be null"); + } + } + } +} diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/StandardProvider.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/StandardProvider.java new file mode 100644 index 000000000..9745cbcc4 --- /dev/null +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/StandardProvider.java @@ -0,0 +1,227 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.chain; + +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Standard credential provider slots in the AWS default credential chain. + * + *

These are ordered from highest to lowest priority. If no implementation is registered for a slot, that slot is + * skipped in the chain. + * + *

Each slot knows how to cheaply detect whether credentials of that type are likely available + * (via {@link #isDetected()}), and what dependency to suggest if the implementation is missing + * (via {@link #moduleSuggestion()}). + */ +public enum StandardProvider { + /** + * Credentials from JVM system properties. + * + *

Detected when {@code aws.accessKeyId} is set. Skipped on platforms without + * language-level property systems. + */ + JAVA_SYSTEM_PROPERTIES("software.amazon.smithy.java:aws-client-core") { + @Override + public boolean isDetected() { + return System.getProperty("aws.accessKeyId") != null; + } + }, + + /** + * Credentials from environment variables ({@code AWS_ACCESS_KEY_ID}, + * {@code AWS_SECRET_ACCESS_KEY}). + */ + ENVIRONMENT("software.amazon.smithy.java:aws-client-core") { + @Override + public boolean isDetected() { + return System.getenv("AWS_ACCESS_KEY_ID") != null; + } + }, + + /** + * Web identity token from environment variables. + * + *

Detected when both {@code AWS_WEB_IDENTITY_TOKEN_FILE} and {@code AWS_ROLE_ARN} + * are set. Requires an STS module to resolve. + */ + WEB_IDENTITY_TOKEN_ENV("software.amazon.smithy.java:aws-credentials-sts") { + @Override + public boolean isDetected() { + return System.getenv("AWS_WEB_IDENTITY_TOKEN_FILE") != null && System.getenv("AWS_ROLE_ARN") != null; + } + }, + + /** + * Parses AWS shared config/credentials files and stores the result on the + * {@link ChainSetup} for downstream providers. + * + *

This provider does not itself resolve credentials — it returns {@code null} from + * {@code create()}. Its purpose is to make the parsed profile available via + * {@link ChainSetup#profile()} for all subsequent profile-based slots. + */ + SHARED_CONFIG(null) { + @Override + public boolean isDetected() { + var home = System.getProperty("user.home"); + if (home == null) { + return false; + } + var awsDir = Path.of(home, ".aws"); + return Files.exists(awsDir.resolve("credentials")) || Files.exists(awsDir.resolve("config")); + } + }, + + /** + * Profile-based static keys ({@code aws_access_key_id} + {@code aws_secret_access_key}). + * + *

Re-reads from the profile on each resolution to support live reload after invalidation. + */ + PROFILE_STATIC_KEYS(null) { + @Override + public boolean isDetected() { + return false; + } + }, + + /** + * Profile-based session keys ({@code aws_access_key_id} + {@code aws_secret_access_key} + * + {@code aws_session_token}). + * + *

Re-reads from the profile on each resolution to support live reload after invalidation. + */ + PROFILE_SESSION_KEYS(null) { + @Override + public boolean isDetected() { + return false; + } + }, + + /** + * Profile-based assume role ({@code role_arn} with {@code source_profile} or + * {@code credential_source}). + * + *

Requires the STS module. Reads the active profile from {@link ChainSetup#profile()}. + */ + PROFILE_ASSUME_ROLE("software.amazon.smithy.java:aws-credentials-sts") { + @Override + public boolean isDetected() { + return false; + } + }, + + /** + * Profile-based web identity token ({@code web_identity_token_file} + {@code role_arn}). + * + *

Requires the STS module. Reads the active profile from {@link ChainSetup#profile()}. + */ + PROFILE_WEB_IDENTITY("software.amazon.smithy.java:aws-credentials-sts") { + @Override + public boolean isDetected() { + return false; + } + }, + + /** + * Profile-based SSO session ({@code sso_session} + {@code sso_account_id} + + * {@code sso_role_name}). + * + *

Requires the SSO module. Reads the active profile from {@link ChainSetup#profile()}. + */ + PROFILE_SSO_SESSION("software.amazon.smithy.java:aws-credentials-sso") { + @Override + public boolean isDetected() { + return false; + } + }, + + /** + * Profile-based legacy SSO ({@code sso_start_url} + {@code sso_account_id} + + * {@code sso_role_name} + {@code sso_region}). + * + *

Requires the SSO module. Reads the active profile from {@link ChainSetup#profile()}. + */ + PROFILE_LEGACY_SSO("software.amazon.smithy.java:aws-credentials-sso") { + @Override + public boolean isDetected() { + return false; + } + }, + + /** + * Profile-based login session ({@code login_session}). + * + *

Requires the login module. Reads the active profile from {@link ChainSetup#profile()}. + */ + PROFILE_LOGIN("software.amazon.smithy.java:aws-credentials-login") { + @Override + public boolean isDetected() { + return false; + } + }, + + /** + * Profile-based credential process ({@code credential_process}). + * + *

Invokes an external process on each resolution. The command string is captured at + * assembly time from the active profile. + */ + PROFILE_CREDENTIAL_PROCESS(null) { + @Override + public boolean isDetected() { + return false; + } + }, + + /** + * Credentials from an HTTP endpoint (ECS container credentials, EKS pod identity). + * + *

Detected when {@code AWS_CONTAINER_CREDENTIALS_FULL_URI} or + * {@code AWS_CONTAINER_CREDENTIALS_RELATIVE_URI} is set. + */ + ECS_CONTAINER("software.amazon.smithy.java:aws-credentials-ecs") { + @Override + public boolean isDetected() { + return System.getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != null + || System.getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != null; + } + }, + + /** + * Credentials from the EC2 Instance Metadata Service (IMDSv2). + * + *

No cheap detection signal — IMDS requires a network call. Always tried last. + */ + EC2_INSTANCE_METADATA("software.amazon.smithy.java:aws-credentials-imds") { + @Override + public boolean isDetected() { + return false; + } + }; + + private final String moduleSuggestion; + + StandardProvider(String moduleSuggestion) { + this.moduleSuggestion = moduleSuggestion; + } + + /** + * Cheaply detect whether this credential source is likely available in the current environment. + * This must not perform network calls or expensive I/O. + * + * @return {@code true} if signals suggest this source is configured. + */ + public abstract boolean isDetected(); + + /** + * @return the Maven coordinate to suggest when this source is detected but no implementation + * is on the classpath, or {@code null} if no suggestion is available. + */ + public String moduleSuggestion() { + return moduleSuggestion; + } +} diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandler.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandler.java new file mode 100644 index 000000000..6c2284b09 --- /dev/null +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandler.java @@ -0,0 +1,179 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.chain.config; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import software.amazon.smithy.java.auth.api.identity.Identity; +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; +import software.amazon.smithy.java.auth.api.identity.IdentityResult; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.config.AwsConfigCredentialSource; +import software.amazon.smithy.java.aws.config.AwsProfile; +import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; +import software.amazon.smithy.java.aws.credentials.chain.ChainSetup; +import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; +import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; +import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; +import software.amazon.smithy.java.context.Context; +import software.amazon.smithy.java.core.serde.document.Document; +import software.amazon.smithy.java.json.JsonCodec; +import software.amazon.smithy.java.logging.InternalLogger; + +/** + * Resolves credentials by invoking an external process specified by {@code credential_process} + * in the active AWS profile. + * + *

The command string is captured at assembly time and does not change after construction. + * Each call to {@code resolveIdentity()} re-executes the process to obtain fresh credentials, + * so expiring credentials returned by the process are naturally refreshed without caching. + */ +public final class CredentialProcessHandler implements ChainIdentityProvider { + + private static final InternalLogger LOGGER = InternalLogger.getLogger(CredentialProcessHandler.class); + private static final JsonCodec CODEC = JsonCodec.builder().build(); + private static final long TIMEOUT_SECONDS = 60; + private static final int MAX_OUTPUT_BYTES = 64000; + private static final Set FEATURE_IDS = Set.of( + new CredentialFeatureId("v"), + new CredentialFeatureId("w")); + + @Override + public String name() { + return "CredentialProcess"; + } + + @Override + public OrderingConstraint ordering() { + return new OrderingConstraint.Standard(StandardProvider.PROFILE_CREDENTIAL_PROCESS); + } + + @Override + public Set featureIds() { + return FEATURE_IDS; + } + + @Override + public void setup(Class identityType, ChainSetup setup) { + if (identityType != AwsCredentialsIdentity.class) { + return; + } + AwsProfile profile = setup.profile(); + if (profile == null) { + return; + } + for (AwsConfigCredentialSource source : profile.credentialSources()) { + if (source instanceof AwsConfigCredentialSource.CredentialProcess(String commandLine)) { + setup.addResolver(new Resolver(commandLine)); + return; + } + } + } + + private record Resolver(String commandLine) implements IdentityResolver { + @Override + public Class identityType() { + return AwsCredentialsIdentity.class; + } + + @Override + public IdentityResult resolveIdentity(Context ctx) { + try { + return execute(commandLine); + } catch (IOException | InterruptedException e) { + return IdentityResult.ofError(CredentialProcessHandler.class, + "credential_process failed: " + e.getMessage()); + } + } + } + + private static IdentityResult execute(String commandLine) + throws IOException, InterruptedException { + List cmd = buildCommand(commandLine); + Process process = new ProcessBuilder(cmd).redirectErrorStream(false).start(); + byte[] buf = new byte[MAX_OUTPUT_BYTES + 1]; + String stdout; + String stderr; + try (var stdoutStream = process.getInputStream(); var stderrStream = process.getErrorStream()) { + stdout = readLimited(stdoutStream, buf); + stderr = readLimited(stderrStream, buf); + } finally { + process.destroy(); + } + boolean finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + return IdentityResult.ofError(CredentialProcessHandler.class, + "credential_process timed out after " + TIMEOUT_SECONDS + "s"); + } + int exitCode = process.exitValue(); + if (exitCode != 0) { + LOGGER.debug("credential_process exited with code {}", exitCode); + String msg = stderr.isBlank() ? "credential_process exited with code " + exitCode : stderr.strip(); + return IdentityResult.ofError(CredentialProcessHandler.class, msg); + } + return parseOutput(stdout); + } + + private static List buildCommand(String commandLine) { + if (System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("windows")) { + return List.of("cmd.exe", "/C", commandLine); + } + return List.of("sh", "-c", commandLine); + } + + private static String readLimited(InputStream in, byte[] buf) throws IOException { + int n = in.readNBytes(buf, 0, buf.length); + if (n == buf.length) { + throw new IOException("credential_process output exceeded " + MAX_OUTPUT_BYTES + " bytes"); + } + return new String(buf, 0, n, StandardCharsets.UTF_8); + } + + private static IdentityResult parseOutput(String json) { + Document doc = CODEC.createDeserializer(json.getBytes(StandardCharsets.UTF_8)).readDocument(); + Document versionNode = doc.getMember("Version"); + if (versionNode != null && versionNode.asInteger() != 1) { + return IdentityResult.ofError(CredentialProcessHandler.class, + "credential_process output has unsupported Version: " + versionNode.asInteger()); + } + String accessKeyId = stringMember(doc, "AccessKeyId"); + String secretAccessKey = stringMember(doc, "SecretAccessKey"); + if (accessKeyId == null || secretAccessKey == null) { + return IdentityResult.ofError(CredentialProcessHandler.class, + "credential_process output missing required AccessKeyId or SecretAccessKey"); + } + String sessionToken = stringMember(doc, "SessionToken"); + String accountId = stringMember(doc, "AccountId"); + String expirationStr = stringMember(doc, "Expiration"); + Instant expiration = null; + if (expirationStr != null && !expirationStr.isEmpty()) { + try { + expiration = Instant.parse(expirationStr); + } catch (DateTimeParseException e) { + LOGGER.warn("credential_process returned unparseable Expiration: {}", expirationStr); + } + } + return IdentityResult.of(AwsCredentialsIdentity.create( + accessKeyId, + secretAccessKey, + sessionToken, + expiration, + accountId)); + } + + private static String stringMember(Document doc, String name) { + Document member = doc.getMember(name); + return member == null ? null : member.asString(); + } +} diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SessionKeysHandler.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SessionKeysHandler.java new file mode 100644 index 000000000..83dbf9b97 --- /dev/null +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SessionKeysHandler.java @@ -0,0 +1,81 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.chain.config; + +import java.util.Set; +import software.amazon.smithy.java.auth.api.identity.Identity; +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; +import software.amazon.smithy.java.auth.api.identity.IdentityResult; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.config.AwsConfigCredentialSource; +import software.amazon.smithy.java.aws.config.AwsProfile; +import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; +import software.amazon.smithy.java.aws.credentials.chain.ChainSetup; +import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; +import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; +import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; +import software.amazon.smithy.java.context.Context; + +/** + * Resolves {@link AwsConfigCredentialSource.SessionKeys} from the active profile. + * Re-reads from the setup's profile on each resolution to support live reload. + * Registers as terminal — session keys cannot fail. + */ +public final class SessionKeysHandler implements ChainIdentityProvider { + + private static final Set FEATURE_IDS = Set.of(new CredentialFeatureId("n")); + private static final IdentityResult NO_PROFILE = + IdentityResult.ofError(SessionKeysHandler.class, "No active profile"); + private static final IdentityResult NOT_FOUND = + IdentityResult.ofError(SessionKeysHandler.class, "No session keys in profile"); + + @Override + public String name() { + return "SessionKeys"; + } + + @Override + public OrderingConstraint ordering() { + return new OrderingConstraint.Standard(StandardProvider.PROFILE_SESSION_KEYS); + } + + @Override + public Set featureIds() { + return FEATURE_IDS; + } + + @Override + public void setup(Class identityType, ChainSetup setup) { + if (identityType != AwsCredentialsIdentity.class) { + return; + } + AwsProfile profile = setup.profile(); + if (profile == null) { + return; + } + for (AwsConfigCredentialSource source : profile.credentialSources()) { + if (source instanceof AwsConfigCredentialSource.SessionKeys s) { + IdentityResult result = IdentityResult.of( + AwsCredentialsIdentity.create( + s.accessKeyId(), + s.secretAccessKey(), + s.sessionToken(), + null, + s.accountId())); + setup.addTerminalResolver(new IdentityResolver() { + public IdentityResult resolveIdentity(Context c) { + return result; + } + + public Class identityType() { + return AwsCredentialsIdentity.class; + } + }); + return; + } + } + } +} diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SharedConfigProvider.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SharedConfigProvider.java new file mode 100644 index 000000000..13a695d3d --- /dev/null +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SharedConfigProvider.java @@ -0,0 +1,45 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.chain.config; + +import software.amazon.smithy.java.auth.api.identity.Identity; +import software.amazon.smithy.java.aws.config.AwsProfileFile; +import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; +import software.amazon.smithy.java.aws.credentials.chain.ChainSetup; +import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; +import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; + +/** + * Claims the {@link software.amazon.smithy.java.aws.credentials.chain.StandardProvider#SHARED_CONFIG} + * slot. Parses the AWS config/credentials files and stores the result on the {@link ChainSetup} + * for downstream providers. Does not register any resolver. + */ +public final class SharedConfigProvider implements ChainIdentityProvider { + + @Override + public String name() { + return "SharedConfig"; + } + + @Override + public OrderingConstraint ordering() { + return new OrderingConstraint.Standard(StandardProvider.SHARED_CONFIG); + } + + @Override + public void setup(Class identityType, ChainSetup setup) { + AwsProfileFile profileFile = AwsProfileFile.loadSilently(); + if (profileFile != null) { + setup.setProfileFile(profileFile); + String name = setup.profileNameOverride(); + if (name == null) { + setup.setProfile(profileFile.activeProfile()); + } else { + setup.setProfile(profileFile.profile(name)); + } + } + } +} diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/StaticKeysHandler.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/StaticKeysHandler.java new file mode 100644 index 000000000..d3b9267ea --- /dev/null +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/StaticKeysHandler.java @@ -0,0 +1,81 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.chain.config; + +import java.util.Set; +import software.amazon.smithy.java.auth.api.identity.Identity; +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; +import software.amazon.smithy.java.auth.api.identity.IdentityResult; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.config.AwsConfigCredentialSource; +import software.amazon.smithy.java.aws.config.AwsProfile; +import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; +import software.amazon.smithy.java.aws.credentials.chain.ChainSetup; +import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; +import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; +import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; +import software.amazon.smithy.java.context.Context; + +/** + * Resolves {@link AwsConfigCredentialSource.StaticKeys} from the active profile. + * Re-reads from the setup's profile on each resolution to support live reload. + * Registers as terminal — static keys cannot fail. + */ +public final class StaticKeysHandler implements ChainIdentityProvider { + + private static final Set FEATURE_IDS = Set.of(new CredentialFeatureId("n")); + private static final IdentityResult NO_PROFILE = + IdentityResult.ofError(StaticKeysHandler.class, "No active profile"); + private static final IdentityResult NOT_FOUND = + IdentityResult.ofError(StaticKeysHandler.class, "No static keys in profile"); + + @Override + public String name() { + return "StaticKeys"; + } + + @Override + public OrderingConstraint ordering() { + return new OrderingConstraint.Standard(StandardProvider.PROFILE_STATIC_KEYS); + } + + @Override + public Set featureIds() { + return FEATURE_IDS; + } + + @Override + public void setup(Class identityType, ChainSetup setup) { + if (identityType != AwsCredentialsIdentity.class) { + return; + } + AwsProfile profile = setup.profile(); + if (profile == null) { + return; + } + for (AwsConfigCredentialSource source : profile.credentialSources()) { + if (source instanceof AwsConfigCredentialSource.StaticKeys s) { + IdentityResult result = IdentityResult.of( + AwsCredentialsIdentity.create( + s.accessKeyId(), + s.secretAccessKey(), + null, + null, + s.accountId())); + setup.addTerminalResolver(new IdentityResolver() { + public IdentityResult resolveIdentity(Context c) { + return result; + } + + public Class identityType() { + return AwsCredentialsIdentity.class; + } + }); + return; + } + } + } +} diff --git a/aws/aws-credential-chain/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider b/aws/aws-credential-chain/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider new file mode 100644 index 000000000..f2a4dafcf --- /dev/null +++ b/aws/aws-credential-chain/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider @@ -0,0 +1,4 @@ +software.amazon.smithy.java.aws.credentials.chain.config.SharedConfigProvider +software.amazon.smithy.java.aws.credentials.chain.config.StaticKeysHandler +software.amazon.smithy.java.aws.credentials.chain.config.SessionKeysHandler +software.amazon.smithy.java.aws.credentials.chain.config.CredentialProcessHandler diff --git a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java new file mode 100644 index 000000000..d65cdaa9b --- /dev/null +++ b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java @@ -0,0 +1,209 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.chain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.java.auth.api.identity.Identity; +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; +import software.amazon.smithy.java.auth.api.identity.IdentityResult; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.context.Context; + +class AwsCredentialChainTest { + @Test + void standardProvidersAreOrderedByEnumOrder() { + var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, + List.of( + registration("imds", + new OrderingConstraint.Standard(StandardProvider.EC2_INSTANCE_METADATA), + errorResolver("imds")), + registration("env", + new OrderingConstraint.Standard(StandardProvider.ENVIRONMENT), + errorResolver("env")), + registration("profile", + new OrderingConstraint.Standard(StandardProvider.SHARED_CONFIG), + errorResolver("profile"))), + null); + + assertEquals(List.of("env", "profile", "imds"), chain.providerNames()); + } + + @Test + void firstSuccessfulProviderWins() { + var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, + List.of( + registration("env", + new OrderingConstraint.Standard(StandardProvider.ENVIRONMENT), + errorResolver("env")), + registration("profile", + new OrderingConstraint.Standard(StandardProvider.SHARED_CONFIG), + staticResolver("AK", "SK"))), + null); + IdentityResult result = chain.resolveIdentity(Context.empty()); + + assertNotNull(result.identity()); + assertEquals("AK", result.identity().accessKeyId()); + } + + @Test + void allFailReturnsAggregatedError() { + var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, + List.of( + registration("env", + new OrderingConstraint.Standard(StandardProvider.ENVIRONMENT), + errorResolver("no env")), + registration("profile", + new OrderingConstraint.Standard(StandardProvider.SHARED_CONFIG), + errorResolver("no profile"))), + null); + IdentityResult result = chain.resolveIdentity(Context.empty()); + + assertNull(result.identity()); + assertTrue(result.error().contains("no env")); + assertTrue(result.error().contains("no profile")); + } + + @Test + void duplicateSlotThrows() { + assertThrows(IllegalStateException.class, + () -> CredentialChain.assemble(AwsCredentialsIdentity.class, + List.of( + registration("a", + new OrderingConstraint.Standard(StandardProvider.ENVIRONMENT), + errorResolver("a")), + registration("b", + new OrderingConstraint.Standard(StandardProvider.ENVIRONMENT), + errorResolver("b"))), + null)); + } + + @Test + void relativeAfterInsertsCorrectly() { + var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, + List.of( + registration("env", + new OrderingConstraint.Standard(StandardProvider.ENVIRONMENT), + errorResolver("env")), + registration("profile", + new OrderingConstraint.Standard(StandardProvider.SHARED_CONFIG), + errorResolver("profile")), + registration("custom", + new OrderingConstraint.After(StandardProvider.ENVIRONMENT), + errorResolver("custom"))), + null); + + assertEquals(List.of("env", "custom", "profile"), chain.providerNames()); + } + + @Test + void relativeBeforeInsertsCorrectly() { + var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, + List.of( + registration("env", + new OrderingConstraint.Standard(StandardProvider.ENVIRONMENT), + errorResolver("env")), + registration("profile", + new OrderingConstraint.Standard(StandardProvider.SHARED_CONFIG), + errorResolver("profile")), + registration("custom", + new OrderingConstraint.Before(StandardProvider.SHARED_CONFIG), + errorResolver("custom"))), + null); + + assertEquals(List.of("env", "custom", "profile"), chain.providerNames()); + } + + @Test + void relativeToUnclaimedSlotAppendsAtEnd() { + var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, + List.of( + registration("env", + new OrderingConstraint.Standard(StandardProvider.ENVIRONMENT), + errorResolver("env")), + registration("custom", + new OrderingConstraint.After(StandardProvider.EC2_INSTANCE_METADATA), + errorResolver("custom"))), + null); + + assertEquals(List.of("env", "custom"), chain.providerNames()); + } + + @Test + void duplicateNameThrows() { + assertThrows(IllegalStateException.class, + () -> CredentialChain.assemble(AwsCredentialsIdentity.class, + List.of( + registration("env", + new OrderingConstraint.Standard(StandardProvider.ENVIRONMENT), + errorResolver("env")), + registration("env", + new OrderingConstraint.Standard(StandardProvider.JAVA_SYSTEM_PROPERTIES), + errorResolver("env2"))), + null)); + } + + @Test + void emptyChainReturnsDescriptiveError() { + var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, List.of(), null); + IdentityResult result = chain.resolveIdentity(Context.empty()); + + assertNull(result.identity()); + assertTrue(result.error().contains("No credential providers were discovered")); + } + + private static ChainIdentityProvider registration( + String name, + OrderingConstraint ordering, + IdentityResolver resolver + ) { + return new ChainIdentityProvider() { + public String name() { + return name; + } + + public OrderingConstraint ordering() { + return ordering; + } + + public void setup(Class identityType, ChainSetup setup) { + setup.addResolver(resolver); + } + }; + } + + private static IdentityResolver errorResolver(String msg) { + IdentityResult result = IdentityResult.ofError(AwsCredentialChainTest.class, msg); + return new IdentityResolver<>() { + public IdentityResult resolveIdentity(Context ctx) { + return result; + } + + public Class identityType() { + return AwsCredentialsIdentity.class; + } + }; + } + + private static IdentityResolver staticResolver(String ak, String sk) { + IdentityResult result = IdentityResult.of(AwsCredentialsIdentity.create(ak, sk)); + return new IdentityResolver<>() { + public IdentityResult resolveIdentity(Context ctx) { + return result; + } + + public Class identityType() { + return AwsCredentialsIdentity.class; + } + }; + } +} diff --git a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java new file mode 100644 index 000000000..4aa61d701 --- /dev/null +++ b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java @@ -0,0 +1,160 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.chain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.java.auth.api.identity.Identity; +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; +import software.amazon.smithy.java.auth.api.identity.IdentityResult; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.client.core.CallContext; +import software.amazon.smithy.java.client.core.FeatureId; +import software.amazon.smithy.java.context.Context; + +class FeatureIdTest { + + @Test + void successfulProviderEmitsFeatureId() { + var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, + List.of( + provider("env", + StandardProvider.ENVIRONMENT, + Set.of(new CredentialFeatureId("g")), + staticResolver("AK", "SK"))), + null); + + Context ctx = Context.create(); + ctx.put(CallContext.FEATURE_IDS, new HashSet<>()); + + chain.resolveIdentity(ctx); + + Set ids = ctx.get(CallContext.FEATURE_IDS); + assertEquals(1, ids.size()); + assertEquals("g", ids.iterator().next().getShortName()); + } + + @Test + void failedProviderDoesNotEmitFeatureId() { + var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, + List.of( + provider("env", + StandardProvider.ENVIRONMENT, + Set.of(new CredentialFeatureId("g")), + errorResolver("no creds")), + provider("profile", + StandardProvider.SHARED_CONFIG, + Set.of(new CredentialFeatureId("n")), + staticResolver("AK", "SK"))), + null); + + Context ctx = Context.create(); + ctx.put(CallContext.FEATURE_IDS, new HashSet<>()); + + chain.resolveIdentity(ctx); + + Set ids = ctx.get(CallContext.FEATURE_IDS); + assertEquals(1, ids.size()); + assertEquals("n", ids.iterator().next().getShortName()); + } + + @Test + void multipleFeatureIdsEmitted() { + var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, + List.of( + provider("proc", + StandardProvider.SHARED_CONFIG, + Set.of(new CredentialFeatureId("v"), new CredentialFeatureId("w")), + staticResolver("AK", "SK"))), + null); + + Context ctx = Context.create(); + ctx.put(CallContext.FEATURE_IDS, new HashSet<>()); + + chain.resolveIdentity(ctx); + + Set ids = ctx.get(CallContext.FEATURE_IDS); + assertEquals(2, ids.size()); + Set names = new HashSet<>(); + for (FeatureId id : ids) { + names.add(id.getShortName()); + } + assertTrue(names.contains("v")); + assertTrue(names.contains("w")); + } + + @Test + void noFeatureIdsWhenContextKeyNotSet() { + var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, + List.of( + provider("env", + StandardProvider.ENVIRONMENT, + Set.of(new CredentialFeatureId("g")), + staticResolver("AK", "SK"))), + null); + + // No FEATURE_IDS in context — should not throw. + Context ctx = Context.create(); + var result = chain.resolveIdentity(ctx); + assertEquals("AK", result.identity().accessKeyId()); + } + + private static ChainIdentityProvider provider( + String name, + StandardProvider slot, + Set featureIds, + IdentityResolver resolver + ) { + return new ChainIdentityProvider() { + public String name() { + return name; + } + + public Set featureIds() { + return featureIds; + } + + public OrderingConstraint ordering() { + return new OrderingConstraint.Standard(slot); + } + + public void setup(Class identityType, ChainSetup setup) { + setup.addResolver(resolver); + } + }; + } + + private static IdentityResolver staticResolver(String ak, String sk) { + IdentityResult result = IdentityResult.of(AwsCredentialsIdentity.create(ak, sk)); + return new IdentityResolver<>() { + public IdentityResult resolveIdentity(Context ctx) { + return result; + } + + public Class identityType() { + return AwsCredentialsIdentity.class; + } + }; + } + + private static IdentityResolver errorResolver(String msg) { + IdentityResult result = IdentityResult.ofError(FeatureIdTest.class, msg); + return new IdentityResolver<>() { + public IdentityResult resolveIdentity(Context ctx) { + return result; + } + + public Class identityType() { + return AwsCredentialsIdentity.class; + } + }; + } +} diff --git a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandlerTest.java b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandlerTest.java new file mode 100644 index 000000000..264dbd3ea --- /dev/null +++ b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandlerTest.java @@ -0,0 +1,149 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.chain.config; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; +import software.amazon.smithy.java.auth.api.identity.IdentityResult; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.config.AwsConfigCredentialSource; +import software.amazon.smithy.java.aws.config.AwsProfileFile; +import software.amazon.smithy.java.aws.credentials.chain.ChainSetup; +import software.amazon.smithy.java.context.Context; + +class CredentialProcessHandlerTest { + + @Test + void successfulProcessReturnsCredentials(@TempDir Path tmp) throws IOException { + Path script = writeScript(tmp, + """ + #!/bin/sh + echo '{"Version": 1, "AccessKeyId": "AKIA_PROC", "SecretAccessKey": "SECRET_PROC", "SessionToken": "TOK", "AccountId": "123456789012"}' + """); + + AwsConfigCredentialSource.CredentialProcess source = + new AwsConfigCredentialSource.CredentialProcess(script.toString()); + IdentityResult result = createFromProfileResult(source); + + assertNotNull(result); + AwsCredentialsIdentity id = result.unwrap(); + assertEquals("AKIA_PROC", id.accessKeyId()); + assertEquals("SECRET_PROC", id.secretAccessKey()); + assertEquals("TOK", id.sessionToken()); + assertEquals("123456789012", id.accountId()); + } + + @Test + void processWithExpirationParsesTimestamp(@TempDir Path tmp) throws IOException { + Path script = writeScript(tmp, + """ + #!/bin/sh + echo '{"Version": 1, "AccessKeyId": "AK", "SecretAccessKey": "SK", "Expiration": "2099-01-01T00:00:00Z"}' + """); + + AwsConfigCredentialSource.CredentialProcess source = + new AwsConfigCredentialSource.CredentialProcess(script.toString()); + AwsCredentialsIdentity id = createFromProfileResult(source).unwrap(); + assertNotNull(id.expirationTime()); + assertEquals("2099-01-01T00:00:00Z", id.expirationTime().toString()); + } + + @Test + void processWithoutSessionTokenReturnsBasicCredentials(@TempDir Path tmp) throws IOException { + Path script = writeScript(tmp, """ + #!/bin/sh + echo '{"Version": 1, "AccessKeyId": "AK", "SecretAccessKey": "SK"}' + """); + + AwsConfigCredentialSource.CredentialProcess source = + new AwsConfigCredentialSource.CredentialProcess(script.toString()); + AwsCredentialsIdentity id = createFromProfileResult(source).unwrap(); + assertEquals("AK", id.accessKeyId()); + assertEquals("SK", id.secretAccessKey()); + assertNull(id.sessionToken()); + } + + @Test + void nonZeroExitCodeReturnsError(@TempDir Path tmp) throws IOException { + Path script = writeScript(tmp, """ + #!/bin/sh + echo "Something went wrong" >&2 + exit 1 + """); + + AwsConfigCredentialSource.CredentialProcess source = + new AwsConfigCredentialSource.CredentialProcess(script.toString()); + IdentityResult result = createFromProfileResult(source); + + assertNotNull(result); + assertNull(result.identity()); + assertTrue(result.error().contains("Something went wrong")); + } + + @Test + void missingRequiredFieldsReturnsError(@TempDir Path tmp) throws IOException { + Path script = writeScript(tmp, """ + #!/bin/sh + echo '{"Version": 1, "AccessKeyId": "AK"}' + """); + + AwsConfigCredentialSource.CredentialProcess source = + new AwsConfigCredentialSource.CredentialProcess(script.toString()); + IdentityResult result = createFromProfileResult(source); + + assertNull(result.identity()); + assertTrue(result.error().contains("SecretAccessKey")); + } + + @Test + void returnsNullForNonCredentialProcessSource() { + AwsConfigCredentialSource.StaticKeys other = new AwsConfigCredentialSource.StaticKeys("AK", "SK", null); + assertNull(createFromProfileResult(other)); + } + + private static Path writeScript(Path tmp, String content) throws IOException { + Path script = tmp.resolve("cred-proc.sh"); + Files.writeString(script, content, StandardCharsets.UTF_8); + script.toFile().setExecutable(true); + return script; + } + + private IdentityResult createFromProfileResult(AwsConfigCredentialSource source) { + if (!(source instanceof AwsConfigCredentialSource.CredentialProcess cp)) { + return null; + } + var handler = new CredentialProcessHandler(); + var setup = ChainSetup.builder().build(); + try { + Path configPath = Files.createTempFile("aws-config", ".ini"); + Files.writeString(configPath, "[default]\ncredential_process=" + cp.commandLine() + "\n"); + var file = AwsProfileFile.builder().configFile(configPath).credentialsFile(null).build(); + setup.setProfileFile(file); + setup.setProfile(file.activeProfile()); + } catch (IOException e) { + throw new RuntimeException(e); + } + setup.setCurrentProvider(handler); + handler.setup(AwsCredentialsIdentity.class, setup); + var resolvers = setup.resolvers(); + if (resolvers.isEmpty()) { + return null; + } + @SuppressWarnings("unchecked") + var r = (IdentityResolver) resolvers.getFirst().resolver(); + return r.resolveIdentity(Context.empty()); + } +} diff --git a/aws/aws-credential-chain/src/test/resources/software/amazon/smithy/java/aws/credentials/chain/config/config-file-location-tests.json b/aws/aws-credential-chain/src/test/resources/software/amazon/smithy/java/aws/credentials/chain/config/config-file-location-tests.json new file mode 100644 index 000000000..2029792d8 --- /dev/null +++ b/aws/aws-credential-chain/src/test/resources/software/amazon/smithy/java/aws/credentials/chain/config/config-file-location-tests.json @@ -0,0 +1,135 @@ +{ + "description": "These are test descriptions that specify which files and profiles should be loaded based on the specified environment variables.", + + "tests": [ + { + "name": "User home is loaded from $HOME with highest priority on non-windows platforms.", + "environment": { + "HOME": "/home/user", + "USERPROFILE": "ignored", + "HOMEDRIVE": "ignored", + "HOMEPATH": "ignored" + }, + "languageSpecificHome": "ignored", + "platform": "linux", + "profile": "default", + "configLocation": "/home/user/.aws/config", + "credentialsLocation": "/home/user/.aws/credentials" + }, + + { + "name": "User home is loaded using language-specific resolution on non-windows platforms when $HOME is not set.", + "environment": { + "USERPROFILE": "ignored", + "HOMEDRIVE": "ignored", + "HOMEPATH": "ignored" + }, + "languageSpecificHome": "/home/user", + "platform": "linux", + "profile": "default", + "configLocation": "/home/user/.aws/config", + "credentialsLocation": "/home/user/.aws/credentials" + }, + + { + "name": "User home is loaded from $HOME with highest priority on windows platforms.", + "environment": { + "HOME": "C:\\users\\user", + "USERPROFILE": "ignored", + "HOMEDRIVE": "ignored", + "HOMEPATH": "ignored" + }, + "languageSpecificHome": "ignored", + "platform": "windows", + "profile": "default", + "configLocation": "C:\\users\\user\\.aws\\config", + "credentialsLocation": "C:\\users\\user\\.aws\\credentials" + }, + + { + "name": "User home is loaded from $USERPROFILE on windows platforms when $HOME is not set.", + "environment": { + "USERPROFILE": "C:\\users\\user", + "HOMEDRIVE": "ignored", + "HOMEPATH": "ignored" + }, + "languageSpecificHome": "ignored", + "platform": "windows", + "profile": "default", + "configLocation": "C:\\users\\user\\.aws\\config", + "credentialsLocation": "C:\\users\\user\\.aws\\credentials" + }, + + { + "name": "User home is loaded from $HOMEDRIVE$HOMEPATH on windows platforms when $HOME and $USERPROFILE are not set.", + "environment": { + "HOMEDRIVE": "C:", + "HOMEPATH": "\\users\\user" + }, + "languageSpecificHome": "ignored", + "platform": "windows", + "profile": "default", + "configLocation": "C:\\users\\user\\.aws\\config", + "credentialsLocation": "C:\\users\\user\\.aws\\credentials" + }, + + { + "name": "User home is loaded using language-specific resolution on windows platforms when no environment variables are set.", + "environment": { + }, + "languageSpecificHome": "C:\\users\\user", + "platform": "windows", + "profile": "default", + "configLocation": "C:\\users\\user\\.aws\\config", + "credentialsLocation": "C:\\users\\user\\.aws\\credentials" + }, + + { + "name": "The default config location can be overridden by the user on non-windows platforms.", + "environment": { + "AWS_CONFIG_FILE": "/other/path/config", + "HOME": "/home/user" + }, + "platform": "linux", + "profile": "default", + "configLocation": "/other/path/config", + "credentialsLocation": "/home/user/.aws/credentials" + }, + + { + "name": "The default credentials location can be overridden by the user on non-windows platforms.", + "environment": { + "AWS_SHARED_CREDENTIALS_FILE": "/other/path/credentials", + "HOME": "/home/user" + }, + "platform": "linux", + "profile": "default", + "configLocation": "/home/user/.aws/config", + "credentialsLocation": "/other/path/credentials" + }, + + { + "name": "The default credentials location can be overridden by the user on windows platforms.", + "environment": { + "AWS_CONFIG_FILE": "C:\\other\\path\\config", + "HOME": "C:\\users\\user" + }, + "platform": "windows", + "profile": "default", + "configLocation": "C:\\other\\path\\config", + "credentialsLocation": "C:\\users\\user\\.aws\\credentials" + }, + + { + "name": "The default credentials location can be overridden by the user on windows platforms.", + "environment": { + "AWS_SHARED_CREDENTIALS_FILE": "C:\\other\\path\\credentials", + "HOME": "C:\\users\\user" + }, + "platform": "windows", + "profile": "default", + "configLocation": "C:\\users\\user\\.aws\\config", + "credentialsLocation": "C:\\other\\path\\credentials" + } + ] +} \ No newline at end of file diff --git a/aws/aws-credential-chain/src/test/resources/software/amazon/smithy/java/aws/credentials/chain/config/config-file-parser-tests.json b/aws/aws-credential-chain/src/test/resources/software/amazon/smithy/java/aws/credentials/chain/config/config-file-parser-tests.json new file mode 100644 index 000000000..7bada6746 --- /dev/null +++ b/aws/aws-credential-chain/src/test/resources/software/amazon/smithy/java/aws/credentials/chain/config/config-file-parser-tests.json @@ -0,0 +1,1572 @@ +{ + "description": "These are test descriptions that describe how to convert a raw configuration and credentials file into an in-memory representation of the config file for profiles and sso-sessions.", + + "tests": [ + { + "name": "Empty files have no profiles.", + "input": { + "configFile" : "" + }, + "output": { + "profiles": {} + } + }, + + { + "name": "Empty profiles have no properties.", + "input": { + "configFile": "[profile foo]\n" + }, + "output": { + "profiles": { + "foo": {} + } + } + }, + + { + "name": "Profile definitions must end with brackets.", + "input": { + "configFile": "[profile foo" + }, + "output": { + "errorContaining": "Section definition must end with ']'" + } + }, + + { + "name": "Profile names should be trimmed.", + "input": { + "configFile": "[profile \tfoo \t]" + }, + "output": { + "profiles": { + "foo": {} + } + } + }, + + { + "name": "Tabs can separate profile names from the section.", + "input": { + "configFile": "[profile\tfoo]" + }, + "output": { + "profiles": { + "foo": {} + } + } + }, + + { + "name": "Properties must be defined in a section.", + "input": { + "configFile": "name = value" + }, + "output": { + "errorContaining": "Expected a section definition" + } + }, + + { + "name": "Profiles can contain properties.", + "input": { + "configFile": "[profile foo]\nname = value" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Windows style line endings are supported.", + "input": { + "configFile": "[profile foo]\r\nname = value" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Equals signs are supported in property values.", + "input": { + "configFile": "[profile foo]\nname = val=ue" + }, + "output": { + "profiles": { + "foo": { + "name": "val=ue" + } + } + } + }, + + { + "name": "Unicode characters are supported in property values.", + "input": { + "configFile": "[profile foo]\nname = 😂" + }, + "output": { + "profiles": { + "foo": { + "name": "😂" + } + } + } + }, + + { + "name": "Profiles can contain multiple properties.", + "input": { + "configFile": "[profile foo]\nname = value\nname2 = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value", + "name2": "value2" + } + } + } + }, + + { + "name": "Property keys and values are trimmed.", + "input": { + "configFile": "[profile foo]\nname \t= \tvalue \t" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Property values can be empty.", + "input": { + "configFile": "[profile foo]\nname =" + }, + "output": { + "profiles": { + "foo": { + "name": "" + } + } + } + }, + + { + "name": "Property key cannot be empty.", + "input": { + "configFile": "[profile foo]\n= value" + }, + "output": { + "errorContaining": "Property did not have a name" + } + }, + + { + "name": "Property definitions must contain an equals sign.", + "input": { + "configFile": "[profile foo]\nkey : value" + }, + "output": { + "errorContaining": "Expected an '=' sign defining a property" + } + }, + + { + "name": "Multiple profiles can be empty.", + "input": { + "configFile": "[profile foo]\n[profile bar]" + }, + "output": { + "profiles": { + "foo": {}, + "bar": {} + } + } + }, + + { + "name": "Multiple profiles can have properties.", + "input": { + "configFile": "[profile foo]\nname = value\n[profile bar]\nname2 = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + }, + "bar": { + "name2": "value2" + } + } + } + }, + + { + "name": "Blank lines are ignored.", + "input": { + "configFile": "\t \n[profile foo]\n\t\n \nname = value\n\t \n[profile bar]\n \t" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + }, + "bar": {} + } + } + }, + + { + "name": "Pound sign comments are ignored.", + "input": { + "configFile": "# Comment\n[profile foo] # Comment\nname = value # Comment with # sign" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Semicolon sign comments are ignored.", + "input": { + "configFile": "; Comment\n[profile foo] ; Comment\nname = value ; Comment with ; sign" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + { + "name": "All comment types can be used together.", + "input": { + "configFile": "# Comment\n[profile foo] ; Comment\nname = value # Comment with ; sign" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Comments can be empty.", + "input": { + "configFile": ";\n[profile foo];\nname = value ;\n" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Comments can be adjacent to profile names.", + "input": { + "configFile": "[profile foo]; Adjacent semicolons\n[profile bar]# Adjacent pound signs" + }, + "output": { + "profiles": { + "foo": {}, + "bar": {} + } + } + }, + + { + "name": "Comments adjacent to values are included in the value.", + "input": { + "configFile": "[profile foo]\nname = value; Adjacent semicolons\nname2 = value# Adjacent pound signs" + }, + "output": { + "profiles": { + "foo": { + "name": "value; Adjacent semicolons", + "name2": "value# Adjacent pound signs" + } + } + } + }, + + { + "name": "Property values can be continued on the next line.", + "input": { + "configFile": "[profile foo]\nname = value\n -continued" + }, + "output": { + "profiles": { + "foo": { + "name": "value\n-continued" + } + } + } + }, + + { + "name": "Property values can be continued with multiple lines.", + "input": { + "configFile": "[profile foo]\nname = value\n -continued\n -and-continued" + }, + "output": { + "profiles": { + "foo": { + "name": "value\n-continued\n-and-continued" + } + } + } + }, + + { + "name": "Continuations are trimmed.", + "input": { + "configFile": "[profile foo]\nname = value\n \t -continued \t " + }, + "output": { + "profiles": { + "foo": { + "name": "value\n-continued" + } + } + } + }, + + { + "name": "Continuation values include pound comments.", + "input": { + "configFile": "[profile foo]\nname = value\n -continued # Comment" + }, + "output": { + "profiles": { + "foo": { + "name": "value\n-continued # Comment" + } + } + } + }, + + { + "name": "Continuation values include semicolon comments.", + "input": { + "configFile": "[profile foo]\nname = value\n -continued ; Comment" + }, + "output": { + "profiles": { + "foo": { + "name": "value\n-continued ; Comment" + } + } + } + }, + + { + "name": "Continuations cannot be used outside of a profile.", + "input": { + "configFile": " -continued" + }, + "output": { + "errorContaining": "Expected a section definition" + } + }, + + { + "name": "Continuations cannot be used outside of a property.", + "input": { + "configFile": "[profile foo]\n -continued" + }, + "output": { + "errorContaining": "Expected a property definition" + } + }, + + { + "name": "Continuations reset with profile definitions.", + "input": { + "configFile": "[profile foo]\nname = value\n[profile foo]\n -continued" + }, + "output": { + "errorContaining": "Expected a property definition" + } + }, + + { + "name": "Duplicate profiles in the same file merge properties.", + "input": { + "configFile": "[profile foo]\nname = value\n[profile foo]\nname2 = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value", + "name2": "value2" + } + } + } + }, + + { + "name": "Duplicate properties in a profile use the last one defined.", + "input": { + "configFile": "[profile foo]\nname = value\nname = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "Duplicate properties in duplicate profiles use the last one defined.", + "input": { + "configFile": "[profile foo]\nname = value\n[profile foo]\nname = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "Default profile with profile prefix overrides default profile without prefix when profile prefix is first.", + "input": { + "configFile": "[profile default]\nname = value\n[default]\nname2 = value2" + }, + "output": { + "profiles": { + "default": { + "name": "value" + } + } + } + }, + + { + "name": "Default profile with profile prefix overrides default profile without prefix when profile prefix is last.", + "input": { + "configFile": "[default]\nname2 = value2\n[profile default]\nname = value" + }, + "output": { + "profiles": { + "default": { + "name": "value" + } + } + } + }, + + { + "name": "Invalid profile names are ignored.", + "input": { + "configFile": "[profile in valid]\nname = value", + "credentialsFile": "[in valid 2]\nname2 = value2" + }, + "output": { + "profiles": {} + } + }, + + { + "name": "Invalid property names are ignored.", + "input": { + "configFile": "[profile foo]\nin valid = value" + }, + "output": { + "profiles": { + "foo": {} + } + } + }, + + { + "name": "All valid identifier characters are supported.", + "input": { + "configFile": "[profile ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+]" + }, + "output": { + "profiles": { + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+": {} + } + } + }, + + { + "name": "All valid property name characters are supported.", + "input": { + "configFile": "[profile foo]\nABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+ = value" + }, + "output": { + "profiles": { + "foo": { + "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+": "value" + } + } + } + }, + + { + "name": "Properties can have sub-properties.", + "input": { + "configFile": "[profile foo]\ns3 =\n name = value" + }, + "output": { + "profiles": { + "foo": { + "s3": { + "name": "value" + } + } + } + } + }, + + { + "name": "Invalid sub-property definitions cause an error.", + "input": { + "configFile": "[profile foo]\ns3 =\n invalid" + }, + "output": { + "errorContaining": "Expected an '=' sign defining a property in sub-property" + } + }, + + { + "name": "Sub-property definitions can have an empty value.", + "input": { + "configFile": "[profile foo]\ns3 =\n name =" + }, + "output": { + "profiles": { + "foo": { + "s3": { + "name": "" + } + } + } + } + }, + + { + "name": "Sub property definitions have pound comments applied to the value.", + "input": { + "configFile": "[profile foo]\ns3 =\n name = value # Comment" + }, + "output": { + "profiles": { + "foo": { + "s3": { + "name": "value # Comment" + } + } + } + } + }, + + { + "name": "Sub property definitions have semicolon comments applied to the value.", + "input": { + "configFile": "[profile foo]\ns3 =\n name = value ; Comment" + }, + "output": { + "profiles": { + "foo": { + "s3": { + "name": "value ; Comment" + } + } + } + } + }, + + { + "name": "Sub-property definitions cannot have an empty name.", + "input": { + "configFile": "[profile foo]\ns3 =\n = value" + }, + "output": { + "errorContaining": "Property did not have a name in sub-property" + } + }, + + { + "name": "Sub-property definitions cannot have an invalid name.", + "input": { + "configFile": "[profile foo]\ns3 =\n in valid = value" + }, + "output": { + "profiles": { + "foo": { + "s3": {} + } + } + } + }, + + { + "name": "Sub-properties can have blank lines that are ignored", + "input": { + "configFile": "[profile foo]\ns3 =\n name = value\n\t \n name2 = value2" + }, + "output": { + "profiles": { + "foo": { + "s3": { + "name": "value", + "name2": "value2" + } + } + } + } + }, + + { + "name": "Profiles duplicated in multiple files are merged.", + "input": { + "configFile": "[profile foo]\nname = value", + "credentialsFile": "[foo]\nname2 = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value", + "name2": "value2" + } + } + } + }, + + { + "name": "Default profiles with mixed prefixes in the config file ignore the one without prefix when merging.", + "input": { + "configFile": "[profile default]\nname = value\n[default]\nname2 = value2\n[profile default]\nname3 = value3" + }, + "output": { + "profiles": { + "default": { + "name": "value", + "name3": "value3" + } + } + } + }, + + { + "name": "Default profiles with mixed prefixes merge with credentials", + "input": { + "configFile": "[profile default]\nname = value\n[default]\nname2 = value2\n[profile default]\nname3 = value3", + "credentialsFile": "[default]\nsecret=foo" + }, + "output": { + "profiles": { + "default": { + "name": "value", + "name3": "value3", + "secret": "foo" + } + } + } + }, + + { + "name": "Duplicate properties between files uses credentials property.", + "input": { + "configFile": "[profile foo]\nname = value", + "credentialsFile": "[foo]\nname = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "Config profiles without prefix are ignored.", + "input": { + "configFile": "[foo]\nname = value" + }, + "output": { + "profiles": {} + } + }, + + { + "name": "Credentials profiles with prefix are ignored.", + "input": { + "credentialsFile": "[profile foo]\nname = value" + }, + "output": { + "profiles": {} + } + }, + + { + "name": "Comment characters adjacent to profile decls", + "input": { + "configFile": "[profile foo]; semicolon\n[profile bar]# pound" + }, + "output": { + "profiles": { + "foo": {}, + "bar": {} + } + } + }, + + { + "name": "Invalid continuation", + "input": { + "configFile": "[profile foo]\nname = value\n[profile foo]\n -continued" + }, + "output": { + "errorContaining": "Expected a property definition, found continuation" + } + }, + + { + "name": "profile name with no space after `profile` is invalid", + "input": { + "configFile": "[profilefoo]\nname = value\n[profile bar]" + }, + "output": { + "profiles": { + "bar": {} + } + } + }, + + { + "name": "profile name with extra whitespace", + "input": { + "configFile": "[ profile foo ]\nname = value\n[profile bar]" + }, + "output": { + "profiles": { + "bar": {}, + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "profile name with extra whitespace in credentials", + "input": { + "credentialsFile": "[ foo ]\nname = value\n[profile bar]" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "properties from an invalid profile name are ignored", + "input": { + "configFile": "[profile foo]\nname = value\n[profile in valid]\nx = 1\n[profile bar]\nname = value2" + }, + "output": { + "profiles": { + "bar": { + "name": "value2" + }, + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Duplicate properties in duplicate profiles use the last one defined (case insensitive).", + "input": { + "configFile": "[profile foo]\nName = value\n[profile foo]\nname = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "Empty files have no sso sessions.", + "input": { + "configFile": "" + }, + "output": { + "ssoSessions": {} + } + }, + + { + "name": "Empty sso sessions have no properties.", + "input": { + "configFile": "[sso-session foo]\n" + }, + "output": { + "ssoSessions": { + "foo": {} + } + } + }, + + { + "name": "sso-sessions without a name are ignored.", + "input": { + "configFile": "[sso-session]\nname = value" + }, + "output": { + "ssoSessions": {} + } + }, + + { + "name": "sso-session definitions must end with brackets.", + "input": { + "configFile": "[sso-session foo" + }, + "output": { + "errorContaining": "Section definition must end with ']'" + } + }, + + { + "name": "sso-session names should be trimmed.", + "input": { + "configFile": "[sso-session \tfoo \t]" + }, + "output": { + "ssoSessions": { + "foo": {} + } + } + }, + + { + "name": "Tabs can separate sso-session names from the section.", + "input": { + "configFile": "[sso-session\tfoo]" + }, + "output": { + "ssoSessions": { + "foo": {} + } + } + }, + + { + "name": "sso-sessions can contain properties.", + "input": { + "configFile": "[sso-session foo]\nname = value" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Windows style line endings are supported.", + "input": { + "configFile": "[sso-session foo]\r\nname = value" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Equals signs are supported in property values.", + "input": { + "configFile": "[sso-session foo]\nname = val=ue" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "val=ue" + } + } + } + }, + + { + "name": "Unicode characters are supported in property values.", + "input": { + "configFile": "[sso-session foo]\nname = 😂" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "😂" + } + } + } + }, + + { + "name": "sso-sessions can contain multiple properties.", + "input": { + "configFile": "[sso-session foo]\nname = value\nname2 = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value", + "name2": "value2" + } + } + } + }, + + { + "name": "Property keys and values are trimmed.", + "input": { + "configFile": "[sso-session foo]\nname \t= \tvalue \t" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Property values can be empty.", + "input": { + "configFile": "[sso-session foo]\nname =" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "" + } + } + } + }, + + { + "name": "Property key cannot be empty.", + "input": { + "configFile": "[sso-session foo]\n= value" + }, + "output": { + "errorContaining": "Property did not have a name" + } + }, + + { + "name": "Property definitions must contain an equals sign.", + "input": { + "configFile": "[sso-session foo]\nkey : value" + }, + "output": { + "errorContaining": "Expected an '=' sign defining a property" + } + }, + + { + "name": "Multiple sso-sessions can be empty.", + "input": { + "configFile": "[sso-session foo]\n[sso-session bar]" + }, + "output": { + "ssoSessions": { + "foo": {}, + "bar": {} + } + } + }, + + { + "name": "Multiple sso-sessions can have properties.", + "input": { + "configFile": "[sso-session foo]\nname = value\n[sso-session bar]\nname2 = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + }, + "bar": { + "name2": "value2" + } + } + } + }, + + { + "name": "Blank lines are ignored.", + "input": { + "configFile": "\t \n[sso-session foo]\n\t\n \nname = value\n\t \n[sso-session bar]\n \t" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + }, + "bar": {} + } + } + }, + + { + "name": "Pound sign comments are ignored.", + "input": { + "configFile": "# Comment\n[sso-session foo] # Comment\nname = value # Comment with # sign" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Semicolon sign comments are ignored.", + "input": { + "configFile": "; Comment\n[sso-session foo] ; Comment\nname = value ; Comment with ; sign" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "All comment types can be used together.", + "input": { + "configFile": "# Comment\n[sso-session foo] ; Comment\nname = value # Comment with ; sign" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Comments can be empty.", + "input": { + "configFile": ";\n[sso-session foo];\nname = value ;\n" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Comments can be adjacent to sso-session names.", + "input": { + "configFile": "[sso-session foo]; Adjacent semicolons\n[sso-session bar]# Adjacent pound signs" + }, + "output": { + "ssoSessions": { + "foo": {}, + "bar": {} + } + } + }, + + { + "name": "Comments adjacent to values are included in the value.", + "input": { + "configFile": "[sso-session foo]\nname = value; Adjacent semicolons\nname2 = value# Adjacent pound signs" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value; Adjacent semicolons", + "name2": "value# Adjacent pound signs" + } + } + } + }, + + { + "name": "Property values can be continued on the next line.", + "input": { + "configFile": "[sso-session foo]\nname = value\n -continued" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value\n-continued" + } + } + } + }, + + { + "name": "Property values can be continued with multiple lines.", + "input": { + "configFile": "[sso-session foo]\nname = value\n -continued\n -and-continued" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value\n-continued\n-and-continued" + } + } + } + }, + + { + "name": "Continuations are trimmed.", + "input": { + "configFile": "[sso-session foo]\nname = value\n \t -continued \t " + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value\n-continued" + } + } + } + }, + + { + "name": "Continuation values include pound comments.", + "input": { + "configFile": "[sso-session foo]\nname = value\n -continued # Comment" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value\n-continued # Comment" + } + } + } + }, + + { + "name": "Continuation values include semicolon comments.", + "input": { + "configFile": "[sso-session foo]\nname = value\n -continued ; Comment" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value\n-continued ; Comment" + } + } + } + }, + + { + "name": "Continuations cannot be used outside of a sso-session.", + "input": { + "configFile": " -continued" + }, + "output": { + "errorContaining": "Expected a section definition" + } + }, + + { + "name": "Continuations cannot be used outside of a property.", + "input": { + "configFile": "[sso-session foo]\n -continued" + }, + "output": { + "errorContaining": "Expected a property definition" + } + }, + + { + "name": "Continuations reset with sso-session definitions.", + "input": { + "configFile": "[sso-session foo]\nname = value\n[sso-session foo]\n -continued" + }, + "output": { + "errorContaining": "Expected a property definition" + } + }, + + { + "name": "Duplicate sso-sessions in the same file merge properties.", + "input": { + "configFile": "[sso-session foo]\nname = value\n[sso-session foo]\nname2 = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value", + "name2": "value2" + } + } + } + }, + + { + "name": "Duplicate properties in an sso-session use the last one defined.", + "input": { + "configFile": "[sso-session foo]\nname = value\nname = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "Duplicate properties in duplicate sso-sessions use the last one defined.", + "input": { + "configFile": "[sso-session foo]\nname = value\n[sso-session foo]\nname = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "Invalid sso-session names are ignored.", + "input": { + "configFile": "[sso-session in valid]\nname = value" + }, + "output": { + "ssoSessions": {} + } + }, + + { + "name": "Invalid property names are ignored.", + "input": { + "configFile": "[sso-session foo]\nin valid = value" + }, + "output": { + "ssoSessions": { + "foo": {} + } + } + }, + + { + "name": "All valid identifier characters are supported.", + "input": { + "configFile": "[sso-session ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+]" + }, + "output": { + "ssoSessions": { + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+": {} + } + } + }, + + { + "name": "All valid property name characters are supported.", + "input": { + "configFile": "[sso-session foo]\nABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+ = value" + }, + "output": { + "ssoSessions": { + "foo": { + "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+": "value" + } + } + } + }, + + { + "name": "Properties can have sub-properties.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n name = value" + }, + "output": { + "ssoSessions": { + "foo": { + "s3": { + "name": "value" + } + } + } + } + }, + + { + "name": "Invalid sub-property definitions cause an error.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n invalid" + }, + "output": { + "errorContaining": "Expected an '=' sign defining a property in sub-property" + } + }, + + { + "name": "Sub-property definitions can have an empty value.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n name =" + }, + "output": { + "ssoSessions": { + "foo": { + "s3": { + "name": "" + } + } + } + } + }, + + { + "name": "Sub property definitions have pound comments applied to the value.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n name = value # Comment" + }, + "output": { + "ssoSessions": { + "foo": { + "s3": { + "name": "value # Comment" + } + } + } + } + }, + + { + "name": "Sub property definitions have semicolon comments applied to the value.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n name = value ; Comment" + }, + "output": { + "ssoSessions": { + "foo": { + "s3": { + "name": "value ; Comment" + } + } + } + } + }, + + { + "name": "Sub-property definitions cannot have an empty name.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n = value" + }, + "output": { + "errorContaining": "Property did not have a name in sub-property" + } + }, + + { + "name": "Sub-property definitions cannot have an invalid name.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n in valid = value" + }, + "output": { + "ssoSessions": { + "foo": { + "s3": {} + } + } + } + }, + + { + "name": "Sub-properties can have blank lines that are ignored", + "input": { + "configFile": "[sso-session foo]\ns3 =\n name = value\n\t \n name2 = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "s3": { + "name": "value", + "name2": "value2" + } + } + } + } + }, + + { + "name": "Config profiles without prefix are ignored.", + "input": { + "configFile": "[foo]\nname = value" + }, + "output": { + "ssoSessions": {} + } + }, + + { + "name": "Comment characters adjacent to sso-session decls", + "input": { + "configFile": "[sso-session foo]; semicolon\n[sso-session bar]# pound" + }, + "output": { + "ssoSessions": { + "foo": {}, + "bar": {} + } + } + }, + + { + "name": "Invalid continuation", + "input": { + "configFile": "[sso-session foo]\nname = value\n[sso-session foo]\n -continued" + }, + "output": { + "errorContaining": "Expected a property definition, found continuation" + } + }, + + { + "name": "profile name with no space after `sso-session` is invalid", + "input": { + "configFile": "[sso-sessionfoo]\nname = value\n[sso-session bar]" + }, + "output": { + "ssoSessions": { + "bar": {} + } + } + }, + + { + "name": "sso-session name with extra whitespace", + "input": { + "configFile": "[ sso-session foo ]\nname = value\n[sso-session bar]" + }, + "output": { + "ssoSessions": { + "bar": {}, + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "properties from an invalid sso-session name are ignored", + "input": { + "configFile": "[sso-session foo]\nname = value\n[sso-session in valid]\nx = 1\n[sso-session bar]\nname = value2" + }, + "output": { + "ssoSessions": { + "bar": { + "name": "value2" + }, + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Duplicate properties in duplicate sso-sessions use the last one defined (case insensitive).", + "input": { + "configFile": "[sso-session foo]\nName = value\n[sso-session foo]\nname = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "sso-sessions in the credentials file are ignored.", + "input": { + "credentialsFile": "[sso-session foo]\nName = value" + }, + "output": { + "ssoSessions": {} + } + }, + + { + "name": "Profile and sso-session can share names.", + "input": { + "configFile": "[profile foo]\nname = value\n[sso-session foo]\nname = value" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + }, + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + } + + ] +} \ No newline at end of file diff --git a/aws/aws-credentials-imds/README.md b/aws/aws-credentials-imds/README.md new file mode 100644 index 000000000..f4e764314 --- /dev/null +++ b/aws/aws-credentials-imds/README.md @@ -0,0 +1,43 @@ +# AWS Credentials IMDS + +Credential provider for EC2 instances using the Instance Metadata Service +(IMDSv2). + +## Dependency + +```kotlin +dependencies { + implementation("software.amazon.smithy.java:aws-credentials-imds:1.1.0") +} +``` + +## Usage + +No code changes are needed. The provider is discovered automatically via +ServiceLoader and claims the `EC2_INSTANCE_METADATA` slot in the credential +chain. + +## Behavior + +- Uses IMDSv2 exclusively (no v1 fallback) +- Tries the extended API first (`/security-credentials-extended/`), falls back + to legacy on 404 +- Caches credentials with background refresh before expiration +- Implements static stability: expired credentials are returned during outages +- Retries with exponential backoff (3 attempts) + +## Configuration + +Configuration is checked in priority order. The first non-null value wins. + + system property > env var > config file + +| Source | Key | Effect | +|--------|-----|--------| +| System property | `aws.disableEc2Metadata=true` | Disables IMDS | +| Environment | `AWS_EC2_METADATA_DISABLED=true` | Disables IMDS | +| Config file | `disable_ec2_metadata=true` | Disables IMDS | +| Environment | `AWS_EC2_METADATA_SERVICE_ENDPOINT` | Overrides endpoint (e.g., for IPv6) | +| System property | `aws.ec2InstanceProfileName` | Skips profile discovery | +| Environment | `AWS_EC2_INSTANCE_PROFILE_NAME` | Skips profile discovery | +| Config file | `ec2_instance_profile_name` | Skips profile discovery | diff --git a/aws/aws-credentials-imds/build.gradle.kts b/aws/aws-credentials-imds/build.gradle.kts new file mode 100644 index 000000000..30a1d5d7d --- /dev/null +++ b/aws/aws-credentials-imds/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + id("smithy-java.module-conventions") +} + +description = "This module provides an IMDS-based credential provider for EC2 instances." + +extra["displayName"] = "Smithy :: Java :: AWS :: Credentials :: IMDS" +extra["moduleName"] = "software.amazon.smithy.java.aws.credentials.imds" + +dependencies { + api(project(":aws:aws-auth-api")) + api(project(":auth-api")) + implementation(project(":aws:aws-credential-chain")) + implementation(project(":logging")) + implementation(project(":codecs:json-codec")) + + testImplementation(project(":core")) +} diff --git a/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsClient.java b/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsClient.java new file mode 100644 index 000000000..c089af50d --- /dev/null +++ b/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsClient.java @@ -0,0 +1,189 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.imds; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.Instant; +import software.amazon.smithy.java.logging.InternalLogger; + +/** + * Minimal IMDSv2 client. Handles token acquisition, caching, and retries with exponential backoff. + */ +final class ImdsClient { + + private static final InternalLogger LOGGER = InternalLogger.getLogger(ImdsClient.class); + private static final String TOKEN_PATH = "/latest/api/token"; + private static final String CREDENTIALS_EXTENDED_PATH = "/latest/meta-data/iam/security-credentials-extended"; + private static final String CREDENTIALS_LEGACY_PATH = "/latest/meta-data/iam/security-credentials"; + private static final Duration TOKEN_TTL = Duration.ofSeconds(21600); + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(1); + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(5); + private static final int MAX_RETRIES = 3; + + private final URI endpoint; + private final SimpleHttpClient httpClient; + private volatile String cachedToken; + private volatile Instant tokenExpiry; + private volatile boolean apiVersionKnown = false; + private volatile boolean useLegacyPath = false; + private volatile String cachedProfileName; + + /** Minimal HTTP client interface for testability. */ + @FunctionalInterface + interface SimpleHttpClient { + HttpResponse send(HttpRequest request) throws IOException, InterruptedException; + } + + @FunctionalInterface + private interface RetryableCall { + String execute(T ctx) throws IOException, InterruptedException; + } + + ImdsClient(URI endpoint) { + this(endpoint, defaultClient()); + } + + ImdsClient(URI endpoint, SimpleHttpClient httpClient) { + this.endpoint = endpoint; + this.httpClient = httpClient; + } + + private static SimpleHttpClient defaultClient() { + HttpClient client = HttpClient.newBuilder().connectTimeout(CONNECT_TIMEOUT).build(); + return request -> client.send(request, HttpResponse.BodyHandlers.ofString()); + } + + /** + * Fetch credentials JSON from IMDS. + */ + String fetchCredentials(String profileName) throws IOException, InterruptedException { + String resolvedProfile = profileName; + if (resolvedProfile == null) { + resolvedProfile = discoverProfileName(); + } + + String basePath = useLegacyPath ? CREDENTIALS_LEGACY_PATH : CREDENTIALS_EXTENDED_PATH; + String body = retryGet(basePath + "/" + resolvedProfile); + + // 404: could be API version mismatch or profile change. + if (body == null && !useLegacyPath && !apiVersionKnown) { + // API version unknown and extended returned 404 — try legacy. + useLegacyPath = true; + if (profileName == null) { + cachedProfileName = null; + resolvedProfile = discoverProfileName(); + } + body = retryGet(CREDENTIALS_LEGACY_PATH + "/" + resolvedProfile); + } else if (body == null && profileName == null) { + // API version known but profile returned 404 — profile changed, re-discover. + cachedProfileName = null; + resolvedProfile = discoverProfileName(); + basePath = useLegacyPath ? CREDENTIALS_LEGACY_PATH : CREDENTIALS_EXTENDED_PATH; + body = retryGet(basePath + "/" + resolvedProfile); + } + + if (body == null) { + throw new IOException("Failed to fetch IMDS credentials after retries"); + } + + return body; + } + + private String discoverProfileName() throws IOException, InterruptedException { + String cached = cachedProfileName; + if (cached != null) { + return cached; + } + String basePath = useLegacyPath ? CREDENTIALS_LEGACY_PATH : CREDENTIALS_EXTENDED_PATH; + String body = retryGet(basePath); + if (body == null && !useLegacyPath) { + useLegacyPath = true; + body = retryGet(CREDENTIALS_LEGACY_PATH); + } + if (body == null) { + throw new IOException("Failed to discover IMDS instance profile name"); + } + apiVersionKnown = true; + cachedProfileName = body.strip(); + return cachedProfileName; + } + + private String retryGet(String path) throws IOException, InterruptedException { + return retry(path, p -> { + String token = getToken(); + HttpRequest request = HttpRequest.newBuilder() + .uri(endpoint.resolve(p)) + .header("X-aws-ec2-metadata-token", token) + .timeout(REQUEST_TIMEOUT) + .GET() + .build(); + HttpResponse response = httpClient.send(request); + if (response.statusCode() == 200) { + return response.body(); + } else if (response.statusCode() == 404) { + return null; + } else { + throw new IOException("IMDS returned status " + response.statusCode() + " for " + p); + } + }); + } + + private String getToken() throws IOException, InterruptedException { + String token = cachedToken; + if (token != null && tokenExpiry != null && Instant.now().isBefore(tokenExpiry)) { + return token; + } + + HttpRequest request = HttpRequest.newBuilder() + .uri(endpoint.resolve(TOKEN_PATH)) + .header("X-aws-ec2-metadata-token-ttl-seconds", String.valueOf(TOKEN_TTL.toSeconds())) + .timeout(REQUEST_TIMEOUT) + .PUT(HttpRequest.BodyPublishers.noBody()) + .build(); + + String newToken = retry(request, r -> { + HttpResponse response = httpClient.send(r); + if (response.statusCode() == 200) { + return response.body(); + } + throw new IOException("IMDS token request returned status " + response.statusCode()); + }); + + if (newToken == null) { + throw new IOException("Failed to acquire IMDSv2 token after retries"); + } else { + cachedToken = newToken; + tokenExpiry = Instant.now().plus(TOKEN_TTL); + return newToken; + } + } + + /** + * Retry a callable with exponential backoff. Returns null if the callable returns null (e.g., 404). + * Throws the last IOException if all retries are exhausted. + */ + private static String retry(T ctx, RetryableCall call) throws IOException, InterruptedException { + IOException lastException = null; + for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) { + if (attempt > 0) { + Thread.sleep((long) Math.pow(2, attempt - 1) * 100); + } + try { + return call.execute(ctx); + } catch (IOException e) { + LOGGER.debug("IMDS request failed (attempt {}): {}", attempt + 1, e.getMessage()); + lastException = e; + } + } + + throw lastException; + } +} diff --git a/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java b/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java new file mode 100644 index 000000000..ab4f5b348 --- /dev/null +++ b/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java @@ -0,0 +1,203 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.imds; + +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.Set; +import software.amazon.smithy.java.auth.api.identity.CachingIdentityResolver; +import software.amazon.smithy.java.auth.api.identity.Identity; +import software.amazon.smithy.java.auth.api.identity.IdentityResult; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsResolver; +import software.amazon.smithy.java.aws.config.AwsProfile; +import software.amazon.smithy.java.aws.config.AwsProfileFile; +import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; +import software.amazon.smithy.java.aws.credentials.chain.ChainSetup; +import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; +import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; +import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; +import software.amazon.smithy.java.context.Context; +import software.amazon.smithy.java.core.serde.document.Document; +import software.amazon.smithy.java.json.JsonCodec; +import software.amazon.smithy.java.logging.InternalLogger; + +/** + * Credential provider that fetches credentials from the EC2 Instance Metadata Service (IMDS). + * + *

Registers in the {@link StandardProvider#EC2_INSTANCE_METADATA} chain slot. Uses IMDSv2 exclusively + * (no v1 fallback). Credentials are cached with static stability enabled per the AWS Static Stability SEP. + */ +public final class ImdsCredentialProvider implements ChainIdentityProvider { + + private static final Set FEATURE_IDS = Set.of(new CredentialFeatureId("0")); + + private static final InternalLogger LOGGER = InternalLogger.getLogger(ImdsCredentialProvider.class); + private static final URI DEFAULT_ENDPOINT = URI.create("http://169.254.169.254"); + private static final JsonCodec CODEC = JsonCodec.builder().build(); + + @Override + public String name() { + return "Ec2InstanceMetadata"; + } + + @Override + public OrderingConstraint ordering() { + return new OrderingConstraint.Standard(StandardProvider.EC2_INSTANCE_METADATA); + } + + @Override + public Set featureIds() { + return FEATURE_IDS; + } + + @Override + public void setup(Class identityType, ChainSetup setup) { + if (identityType != AwsCredentialsIdentity.class) { + return; + } + + AwsProfileFile profileFile = setup.profileFile(); + if (isDisabled(profileFile)) { + return; + } + + URI endpoint = resolveEndpoint(); + String profileName = resolveProfileName(profileFile); + ImdsClient client = new ImdsClient(endpoint); + AwsCredentialsResolver delegate = ctx -> fetchAndParse(client, profileName); + + setup.addResolver(CachingIdentityResolver.builder(delegate) + .executor(setup.executor()) + .allowExpiredCredentials(true) + .build()); + } + + private static IdentityResult fetchAndParse(ImdsClient client, String profileName) { + String json; + try { + json = client.fetchCredentials(profileName); + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + return IdentityResult.ofError(ImdsCredentialProvider.class, + "Failed to fetch credentials from IMDS: " + e.getMessage()); + } + + Document doc = CODEC.createDeserializer(json.getBytes(StandardCharsets.UTF_8)).readDocument(); + String code = stringMember(doc, "Code"); + if (!"Success".equals(code)) { + return IdentityResult.ofError(ImdsCredentialProvider.class, "IMDS returned non-success code: " + code); + } + + String accessKeyId = stringMember(doc, "AccessKeyId"); + String secretAccessKey = stringMember(doc, "SecretAccessKey"); + if (accessKeyId == null || secretAccessKey == null) { + return IdentityResult.ofError(ImdsCredentialProvider.class, + "IMDS response missing AccessKeyId or SecretAccessKey"); + } + + String sessionToken = stringMember(doc, "Token"); + String accountId = stringMember(doc, "AccountId"); + String expirationStr = stringMember(doc, "Expiration"); + Instant expiration = null; + if (expirationStr != null) { + try { + expiration = Instant.parse(expirationStr); + } catch (DateTimeParseException e) { + LOGGER.warn("IMDS returned unparseable Expiration: {}", expirationStr); + } + } + + return IdentityResult.of(AwsCredentialsIdentity.create( + accessKeyId, + secretAccessKey, + sessionToken, + expiration, + accountId)); + } + + private static String stringMember(Document doc, String name) { + Document member = doc.getMember(name); + return member == null ? null : member.asString(); + } + + private static boolean isDisabled(AwsProfileFile profileFile) { + // Priority: system property > env var > config file. First non-null value wins. + String value = System.getProperty("aws.disableEc2Metadata"); + if (value == null) { + value = System.getenv("AWS_EC2_METADATA_DISABLED"); + } + if (value == null) { + value = getProfileProperty(profileFile, "disable_ec2_metadata"); + } + return "true".equalsIgnoreCase(value); + } + + private static URI resolveEndpoint() { + String override = System.getenv("AWS_EC2_METADATA_SERVICE_ENDPOINT"); + return override != null && !override.isEmpty() ? URI.create(override) : DEFAULT_ENDPOINT; + } + + private static String resolveProfileName(AwsProfileFile profileFile) { + // Priority: system property > env var > config file + String prop = System.getProperty("aws.ec2InstanceProfileName"); + if (prop != null) { + return ensureNotBlank("aws.ec2InstanceProfileName", prop); + } + + String env = System.getenv("AWS_EC2_INSTANCE_PROFILE_NAME"); + if (env != null) { + return ensureNotBlank("AWS_EC2_INSTANCE_PROFILE_NAME", env); + } + + String config = getProfileProperty(profileFile, "ec2_instance_profile_name"); + if (config != null) { + return ensureNotBlank("ec2_instance_profile_name", config); + } + + // Will be discovered from IMDS. + return null; + } + + private static String ensureNotBlank(String name, String value) { + if (value.isBlank()) { + throw new IllegalStateException(name + " is set but blank"); + } + return value; + } + + private static String getProfileProperty(AwsProfileFile profileFile, String key) { + if (profileFile == null) { + return null; + } + + // Resolve profile name + String profileName = System.getenv("AWS_PROFILE"); + if (profileName == null || profileName.isEmpty()) { + profileName = "default"; + } + + AwsProfile profile = profileFile.profile(profileName); + return profile != null ? profile.property(key) : null; + } + + /** Resolver returned when IMDS is disabled via configuration. */ + private static final class DisabledResolver implements AwsCredentialsResolver { + private static final IdentityResult DISABLED = IdentityResult.ofError( + ImdsCredentialProvider.class, + "IMDS credential fetching is disabled"); + + @Override + public IdentityResult resolveIdentity(Context requestProperties) { + return DISABLED; + } + } +} diff --git a/aws/aws-credentials-imds/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider b/aws/aws-credentials-imds/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider new file mode 100644 index 000000000..890fe5756 --- /dev/null +++ b/aws/aws-credentials-imds/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider @@ -0,0 +1 @@ +software.amazon.smithy.java.aws.credentials.imds.ImdsCredentialProvider diff --git a/aws/aws-credentials-imds/src/test/java/software/amazon/smithy/java/aws/credentials/imds/ImdsClientTest.java b/aws/aws-credentials-imds/src/test/java/software/amazon/smithy/java/aws/credentials/imds/ImdsClientTest.java new file mode 100644 index 000000000..2cea83c4e --- /dev/null +++ b/aws/aws-credentials-imds/src/test/java/software/amazon/smithy/java/aws/credentials/imds/ImdsClientTest.java @@ -0,0 +1,144 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.imds; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Map; +import java.util.Optional; +import javax.net.ssl.SSLSession; +import org.junit.jupiter.api.Test; + +class ImdsClientTest { + + private static final URI ENDPOINT = URI.create("http://169.254.169.254"); + + @Test + void fetchesCredentialsSuccessfully() throws Exception { + var client = new ImdsClient(ENDPOINT, + mockClient( + response(200, "mock-token"), // PUT token + response(200, "my-role"), // GET discovery + response(200, + "{\"Code\":\"Success\",\"AccessKeyId\":\"AK\",\"SecretAccessKey\":\"SK\",\"Token\":\"T\",\"Expiration\":\"2099-01-01T00:00:00Z\",\"AccountId\":\"123\"}"))); + String json = client.fetchCredentials(null); + assertNotNull(json); + assertTrue(json.contains("AK")); + assertTrue(json.contains("123")); + } + + @Test + void usesProvidedProfileName() throws Exception { + var client = new ImdsClient(ENDPOINT, + mockClient( + response(200, "mock-token"), + response(200, + "{\"Code\":\"Success\",\"AccessKeyId\":\"AK2\",\"SecretAccessKey\":\"SK2\",\"Token\":\"T\",\"Expiration\":\"2099-01-01T00:00:00Z\"}"))); + String json = client.fetchCredentials("custom-role"); + assertNotNull(json); + assertTrue(json.contains("AK2")); + } + + @Test + void fallsBackToLegacyPathOn404() throws Exception { + var client = new ImdsClient(ENDPOINT, + mockClient( + response(200, "mock-token"), + response(404, ""), // extended discovery 404 + response(200, "legacy-role"), // legacy discovery + response(200, + "{\"Code\":\"Success\",\"AccessKeyId\":\"LEG\",\"SecretAccessKey\":\"SK\",\"Token\":\"T\",\"Expiration\":\"2099-01-01T00:00:00Z\"}"))); + String json = client.fetchCredentials(null); + assertNotNull(json); + assertTrue(json.contains("LEG")); + } + + @Test + void throwsWhenTokenFails() { + var client = new ImdsClient(ENDPOINT, + mockClient( + response(500, "error"), + response(500, "error"), + response(500, "error"), + response(500, "error") // all retries fail + )); + assertThrows(IOException.class, () -> client.fetchCredentials(null)); + } + + private static ImdsClient.SimpleHttpClient mockClient(MockResponse... responses) { + Deque queue = new ArrayDeque<>(); + for (MockResponse r : responses) { + queue.add(r); + } + return request -> { + MockResponse r = queue.poll(); + if (r == null) { + return response(404, "").toHttpResponse(request); + } + return r.toHttpResponse(request); + }; + } + + private static MockResponse response(int status, String body) { + return new MockResponse(status, body); + } + + private record MockResponse(int status, String body) { + HttpResponse toHttpResponse(HttpRequest request) { + return new HttpResponse<>() { + @Override + public int statusCode() { + return status; + } + + @Override + public String body() { + return body; + } + + @Override + public HttpRequest request() { + return request; + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return HttpHeaders.of(Map.of(), (a, b) -> true); + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + + @Override + public URI uri() { + return request.uri(); + } + + @Override + public HttpClient.Version version() { + return HttpClient.Version.HTTP_1_1; + } + }; + } + } +} diff --git a/aws/aws-credentials-imds/src/test/java/software/amazon/smithy/java/aws/credentials/imds/ImdsConformanceTest.java b/aws/aws-credentials-imds/src/test/java/software/amazon/smithy/java/aws/credentials/imds/ImdsConformanceTest.java new file mode 100644 index 000000000..a19f5d773 --- /dev/null +++ b/aws/aws-credentials-imds/src/test/java/software/amazon/smithy/java/aws/credentials/imds/ImdsConformanceTest.java @@ -0,0 +1,205 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.imds; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; +import javax.net.ssl.SSLSession; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; +import software.amazon.smithy.java.core.serde.document.Document; +import software.amazon.smithy.java.json.JsonCodec; +import software.amazon.smithy.model.shapes.ShapeType; + +/** + * Runs the IMDS v2.1 conformance test suite using a mock SimpleHttpClient. + */ +class ImdsConformanceTest { + + private static final JsonCodec CODEC = JsonCodec.builder().build(); + private static final URI ENDPOINT = URI.create("http://169.254.169.254"); + + @TestFactory + Stream imdsTests() throws IOException { + byte[] data; + try (InputStream is = getClass().getResourceAsStream("imds-v21-tests.json")) { + assertNotNull(is); + data = is.readAllBytes(); + } + Document root = CODEC.createDeserializer(data).readDocument(); + + List tests = new ArrayList<>(); + for (Document test : root.asList()) { + String summary = test.getMember("summary").asString(); + tests.add(DynamicTest.dynamicTest(summary, () -> runTest(test))); + } + return tests.stream(); + } + + private void runTest(Document test) throws Exception { + Document config = test.getMember("config"); + String profileName = null; + Document profileNameDoc = config.getMember("ec2InstanceProfileName"); + if (profileNameDoc != null && profileNameDoc.isType(ShapeType.STRING)) { + profileName = profileNameDoc.asString(); + } + + // Check if disabled. + Document envVars = config.getMember("envVars"); + if (envVars != null) { + Document disabled = envVars.getMember("AWS_EC2_METADATA_DISABLED"); + if (disabled != null && "true".equalsIgnoreCase(disabled.asString())) { + for (Document outcome : test.getMember("outcomes").asList()) { + assertEquals("no credentials", outcome.getMember("result").asString()); + } + return; + } + } + + // Build path-aware mock: map path -> queue of responses. + Map> pathResponses = new HashMap<>(); + for (Document exp : test.getMember("expectations").asList()) { + String path = exp.getMember("get").asString(); + Document response = exp.getMember("response"); + int status = response.getMember("status").asInteger(); + Document body = response.getMember("body"); + String bodyStr = ""; + if (body != null) { + bodyStr = body.isType(ShapeType.STRING) ? body.asString() : documentToJson(body); + } + pathResponses.computeIfAbsent(path, k -> new ArrayDeque<>()).add(new MockResp(status, bodyStr)); + } + + // Create mock client that routes by path. + ImdsClient.SimpleHttpClient mockClient = request -> { + if ("PUT".equals(request.method())) { + return fakeResponse(200, "mock-token", request); + } + String reqPath = request.uri().getPath(); + Deque queue = pathResponses.get(reqPath); + if (queue == null || queue.isEmpty()) { + return fakeResponse(404, "", request); + } + MockResp r = queue.poll(); + return fakeResponse(r.status, r.body, request); + }; + + ImdsClient client = new ImdsClient(ENDPOINT, mockClient); + + for (Document outcome : test.getMember("outcomes").asList()) { + String expectedResult = outcome.getMember("result").asString(); + String json; + try { + json = client.fetchCredentials(profileName); + } catch (IOException e) { + if ("no credentials".equals(expectedResult) || "invalid profile".equals(expectedResult)) { + continue; + } + throw e; + } + + if ("no credentials".equals(expectedResult) || "invalid profile".equals(expectedResult)) { + continue; + } + + assertNotNull(json, "Expected credentials but got null"); + if (outcome.getMember("accountId") != null) { + Document creds = CODEC.createDeserializer(json.getBytes(StandardCharsets.UTF_8)).readDocument(); + assertEquals(outcome.getMember("accountId").asString(), + creds.getMember("AccountId").asString()); + } + } + } + + private static String documentToJson(Document doc) { + StringBuilder sb = new StringBuilder("{"); + boolean first = true; + for (String key : doc.getMemberNames()) { + if (!first) { + sb.append(","); + } + first = false; + sb.append("\"").append(key).append("\":"); + Document val = doc.getMember(key); + if (val.isType(ShapeType.STRING) || val.isType(ShapeType.ENUM)) { + sb.append("\"").append(val.asString().replace("\\", "\\\\").replace("\"", "\\\"")).append("\""); + } else if (val.isType(ShapeType.MAP) || val.isType(ShapeType.STRUCTURE)) { + sb.append(documentToJson(val)); + } else { + try { + sb.append(val.asNumber()); + } catch (Exception e) { + sb.append("null"); + } + } + } + sb.append("}"); + return sb.toString(); + } + + private static HttpResponse fakeResponse(int status, String body, HttpRequest request) { + return new HttpResponse<>() { + @Override + public int statusCode() { + return status; + } + + @Override + public String body() { + return body; + } + + @Override + public HttpRequest request() { + return request; + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return HttpHeaders.of(Map.of(), (a, b) -> true); + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + + @Override + public URI uri() { + return request.uri(); + } + + @Override + public HttpClient.Version version() { + return HttpClient.Version.HTTP_1_1; + } + }; + } + + private record MockResp(int status, String body) {} +} diff --git a/aws/aws-credentials-imds/src/test/resources/software/amazon/smithy/java/aws/credentials/imds/imds-v21-tests.json b/aws/aws-credentials-imds/src/test/resources/software/amazon/smithy/java/aws/credentials/imds/imds-v21-tests.json new file mode 100644 index 000000000..625022995 --- /dev/null +++ b/aws/aws-credentials-imds/src/test/resources/software/amazon/smithy/java/aws/credentials/imds/imds-v21-tests.json @@ -0,0 +1,653 @@ +[ + { + "summary": "Test IMDS credentials provider with env vars { AWS_EC2_METADATA_DISABLED=true } returns no credentials", + "config": { + "ec2InstanceProfileName": null, + "envVars": { + "AWS_EC2_METADATA_DISABLED": "true" + } + }, + "expectations": [], + "outcomes": [ + { + "result": "no credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider returns valid credentials with account ID", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0001" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0001", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-12T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-12T21:53:17.832308Z", + "UnexpectedElement1": { + "Name": "ignore-me-1" + }, + "AccountId": "123456789101" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0001", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-12T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-12T21:53:17.832308Z", + "UnexpectedElement1": { + "Name": "ignore-me-1" + }, + "AccountId": "123456789101" + } + } + } + ], + "outcomes": [ + { + "result": "credentials", + "accountId": "123456789101" + }, + { + "result": "credentials", + "accountId": "123456789101" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name returns valid credentials with account ID", + "config": { + "ec2InstanceProfileName": "my-profile-0002" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0002", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-13T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-13T21:53:17.832308Z", + "UnexpectedElement2": { + "Name": "ignore-me-2" + }, + "AccountId": "234567891011" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0002", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-13T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-13T21:53:17.832308Z", + "UnexpectedElement2": { + "Name": "ignore-me-2" + }, + "AccountId": "234567891011" + } + } + } + ], + "outcomes": [ + { + "result": "credentials", + "accountId": "234567891011" + }, + { + "result": "credentials", + "accountId": "234567891011" + } + ] + }, + { + "summary": "Test IMDS credentials provider when profile is unstable returns valid credentials with account ID", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0003" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0003", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-14T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-14T21:53:17.832308Z", + "UnexpectedElement3": { + "Name": "ignore-me-3" + }, + "AccountId": "345678910112" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0003", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0003-b" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0003-b", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-14T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-14T21:53:17.832308Z", + "UnexpectedElement3": { + "Name": "ignore-me-3" + }, + "AccountId": "314253647589" + } + } + } + ], + "outcomes": [ + { + "result": "credentials", + "accountId": "345678910112" + }, + { + "result": "credentials", + "accountId": "314253647589" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name when profile is invalid throws an error", + "config": { + "ec2InstanceProfileName": "my-profile-0004" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0004", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0004", + "response": { + "status": 404 + } + } + ], + "outcomes": [ + { + "result": "invalid profile" + } + ] + }, + { + "summary": "Test IMDS credentials provider when account ID is unavailable returns valid credentials", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0005" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0005", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-16T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-16T21:53:17.832308Z", + "UnexpectedElement5": { + "Name": "ignore-me-5" + } + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0005", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-16T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-16T21:53:17.832308Z", + "UnexpectedElement5": { + "Name": "ignore-me-5" + } + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name when account ID is unavailable returns valid credentials", + "config": { + "ec2InstanceProfileName": "my-profile-0006" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0006", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-17T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-17T21:53:17.832308Z", + "UnexpectedElement6": { + "Name": "ignore-me-6" + } + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0006", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-17T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-17T21:53:17.832308Z", + "UnexpectedElement6": { + "Name": "ignore-me-6" + } + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider when account ID is unavailable when profile is unstable returns valid credentials", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0007" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0007", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-18T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-18T21:53:17.832308Z", + "UnexpectedElement7": { + "Name": "ignore-me-7" + } + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0007", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0007-b" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0007-b", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-18T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-18T21:53:17.832308Z", + "UnexpectedElement7": { + "Name": "ignore-me-7" + } + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name when account ID is unavailable when profile is invalid throws an error", + "config": { + "ec2InstanceProfileName": "my-profile-0008" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0008", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0008", + "response": { + "status": 404 + } + } + ], + "outcomes": [ + { + "result": "invalid profile" + } + ] + }, + { + "summary": "Test IMDS credentials provider against legacy API returns valid credentials", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials", + "response": { + "status": 200, + "body": "my-profile-0009" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0009", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-20T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-20T21:53:17.832308Z" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0009", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-20T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-20T21:53:17.832308Z" + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name against legacy API returns valid credentials", + "config": { + "ec2InstanceProfileName": "my-profile-0010" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0010", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0010", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-21T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-21T21:53:17.832308Z" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0010", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-21T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-21T21:53:17.832308Z" + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider against legacy API when profile is unstable returns valid credentials", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials", + "response": { + "status": 200, + "body": "my-profile-0011" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0011", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-22T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-22T21:53:17.832308Z" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0011", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials", + "response": { + "status": 200, + "body": "my-profile-0011-b" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0011-b", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-22T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-22T21:53:17.832308Z" + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name against legacy API when profile is invalid throws an error", + "config": { + "ec2InstanceProfileName": "my-profile-0012" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0012", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0012", + "response": { + "status": 404 + } + } + ], + "outcomes": [ + { + "result": "invalid profile" + } + ] + } +] diff --git a/aws/aws-credentials-sts/build.gradle.kts b/aws/aws-credentials-sts/build.gradle.kts new file mode 100644 index 000000000..fc3dba78a --- /dev/null +++ b/aws/aws-credentials-sts/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + id("smithy-java.module-conventions") +} + +description = "STS-based credential providers (assume-role, web identity token)." + +extra["displayName"] = "Smithy :: Java :: AWS :: Credentials :: STS" +extra["moduleName"] = "software.amazon.smithy.java.aws.credentials.sts" + +dependencies { + implementation(project(":aws:aws-credential-chain")) + implementation(project(":aws:aws-config")) + implementation(project(":aws:aws-auth-api")) + implementation(project(":aws:aws-credentials-imds")) + implementation(project(":auth-api")) + implementation(project(":client:client-core")) + implementation(project(":client:dynamic-client")) + implementation(project(":client:client-rulesengine")) + implementation(project(":aws:client:aws-client-rulesengine")) + implementation(project(":aws:client:aws-client-awsjson")) + implementation(project(":aws:client:aws-client-awsquery")) + implementation(project(":codecs:json-codec", configuration = "shadow")) + implementation(project(":logging")) + implementation("software.amazon.api.models:sts:1.0.5") + testImplementation(project(":client:client-mock-plugin")) + testImplementation(project(":http:http-api")) +} diff --git a/aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/EnvWebIdentityProvider.java b/aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/EnvWebIdentityProvider.java new file mode 100644 index 000000000..d26c0f268 --- /dev/null +++ b/aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/EnvWebIdentityProvider.java @@ -0,0 +1,59 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.sts; + +import java.util.Set; +import software.amazon.smithy.java.auth.api.identity.Identity; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.config.AwsConfigCredentialSource; +import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; +import software.amazon.smithy.java.aws.credentials.chain.ChainSetup; +import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; +import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; +import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; + +/** + * Resolves credentials via STS AssumeRoleWithWebIdentity using environment variables + * ({@code AWS_WEB_IDENTITY_TOKEN_FILE} + {@code AWS_ROLE_ARN}). + */ +public final class EnvWebIdentityProvider implements ChainIdentityProvider { + + private static final Set FEATURE_IDS = Set.of( + new CredentialFeatureId("h"), + new CredentialFeatureId("k")); + + @Override + public String name() { + return "EnvWebIdentity"; + } + + @Override + public OrderingConstraint ordering() { + return new OrderingConstraint.Standard(StandardProvider.WEB_IDENTITY_TOKEN_ENV); + } + + @Override + public Set featureIds() { + return FEATURE_IDS; + } + + @Override + public void setup(Class identityType, ChainSetup setup) { + if (identityType != AwsCredentialsIdentity.class) { + return; + } + + String tokenFile = setup.getenv("AWS_WEB_IDENTITY_TOKEN_FILE"); + if (tokenFile != null) { + String roleArn = setup.getenv("AWS_ROLE_ARN"); + if (roleArn != null) { + String sessionName = setup.getenv("AWS_ROLE_SESSION_NAME"); + var wit = new AwsConfigCredentialSource.WebIdentityToken(roleArn, tokenFile, sessionName, null); + setup.addTerminalResolver(new StsWebIdentityResolver(wit, StsClientFactory.createNoAuth())); + } + } + } +} diff --git a/aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/ProfileAssumeRoleProvider.java b/aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/ProfileAssumeRoleProvider.java new file mode 100644 index 000000000..37b103dcf --- /dev/null +++ b/aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/ProfileAssumeRoleProvider.java @@ -0,0 +1,59 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.sts; + +import java.util.Set; +import software.amazon.smithy.java.auth.api.identity.Identity; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.config.AwsConfigCredentialSource; +import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; +import software.amazon.smithy.java.aws.credentials.chain.ChainSetup; +import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; +import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; +import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; + +/** + * Resolves credentials by assuming an IAM role via STS, configured from + * {@code role_arn} + {@code source_profile} or {@code credential_source} + * in the active AWS profile. + * + *

Handles recursive source_profile chains with cycle detection. + */ +public final class ProfileAssumeRoleProvider implements ChainIdentityProvider { + + private static final Set FEATURE_IDS = Set.of( + new CredentialFeatureId("o"), + new CredentialFeatureId("i")); + + @Override + public String name() { + return "ProfileAssumeRole"; + } + + @Override + public OrderingConstraint ordering() { + return new OrderingConstraint.Standard(StandardProvider.PROFILE_ASSUME_ROLE); + } + + @Override + public Set featureIds() { + return FEATURE_IDS; + } + + @Override + public void setup(Class identityType, ChainSetup setup) { + if (identityType != AwsCredentialsIdentity.class || setup.profile() == null) { + return; + } + + for (AwsConfigCredentialSource source : setup.profile().credentialSources()) { + if (source instanceof AwsConfigCredentialSource.AssumeRole ar) { + setup.addTerminalResolver(new StsAssumeRoleResolver(ar, setup.profileFile())); + return; + } + } + } +} diff --git a/aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/ProfileWebIdentityProvider.java b/aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/ProfileWebIdentityProvider.java new file mode 100644 index 000000000..2ca33a73d --- /dev/null +++ b/aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/ProfileWebIdentityProvider.java @@ -0,0 +1,56 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.sts; + +import java.util.Set; +import software.amazon.smithy.java.auth.api.identity.Identity; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.config.AwsConfigCredentialSource; +import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; +import software.amazon.smithy.java.aws.credentials.chain.ChainSetup; +import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; +import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; +import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; + +/** + * Resolves credentials via STS AssumeRoleWithWebIdentity, configured from + * {@code web_identity_token_file} + {@code role_arn} in the active AWS profile. + */ +public final class ProfileWebIdentityProvider implements ChainIdentityProvider { + + private static final Set FEATURE_IDS = Set.of( + new CredentialFeatureId("q"), + new CredentialFeatureId("k")); + + @Override + public String name() { + return "ProfileWebIdentity"; + } + + @Override + public OrderingConstraint ordering() { + return new OrderingConstraint.Standard(StandardProvider.PROFILE_WEB_IDENTITY); + } + + @Override + public Set featureIds() { + return FEATURE_IDS; + } + + @Override + public void setup(Class identityType, ChainSetup setup) { + if (identityType != AwsCredentialsIdentity.class || setup.profile() == null) { + return; + } + + for (AwsConfigCredentialSource source : setup.profile().credentialSources()) { + if (source instanceof AwsConfigCredentialSource.WebIdentityToken wit) { + setup.addTerminalResolver(new StsWebIdentityResolver(wit, StsClientFactory.createNoAuth())); + return; + } + } + } +} diff --git a/aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/StsAssumeRoleResolver.java b/aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/StsAssumeRoleResolver.java new file mode 100644 index 000000000..63442d6b2 --- /dev/null +++ b/aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/StsAssumeRoleResolver.java @@ -0,0 +1,166 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.sts; + +import java.util.HashSet; +import java.util.Map; +import java.util.Set; +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; +import software.amazon.smithy.java.auth.api.identity.IdentityResult; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.config.AwsConfigCredentialSource; +import software.amazon.smithy.java.aws.config.AwsProfile; +import software.amazon.smithy.java.aws.config.AwsProfileFile; +import software.amazon.smithy.java.aws.credentials.chain.ChainSetup; +import software.amazon.smithy.java.aws.credentials.imds.ImdsCredentialProvider; +import software.amazon.smithy.java.context.Context; +import software.amazon.smithy.java.dynamicclient.DynamicClient; + +/** + * Resolver that calls STS AssumeRole using source credentials resolved from + * a source_profile chain or credential_source. + * + *

Handles recursive source_profile resolution with cycle detection per the + * Assume Role SEP. + */ +final class StsAssumeRoleResolver implements IdentityResolver { + + private final AwsConfigCredentialSource.AssumeRole source; + private final AwsProfileFile profileFile; + + StsAssumeRoleResolver(AwsConfigCredentialSource.AssumeRole source, AwsProfileFile profileFile) { + this.source = source; + this.profileFile = profileFile; + } + + @Override + public Class identityType() { + return AwsCredentialsIdentity.class; + } + + @Override + public IdentityResult resolveIdentity(Context ctx) { + AwsCredentialsIdentity sourceCredentials = resolveSourceCredentials(source, new HashSet<>()); + return callAssumeRole(sourceCredentials, source.roleArn(), source.externalId()); + } + + private AwsCredentialsIdentity resolveSourceCredentials( + AwsConfigCredentialSource.AssumeRole ar, + Set visited + ) { + if (ar.sourceProfile() != null) { + return resolveFromSourceProfile(ar.sourceProfile(), visited); + } else if (ar.credentialSource() != null) { + return resolveFromCredentialSource(ar.credentialSource()); + } + throw new IllegalStateException("Profile with role_arn must have either source_profile or credential_source"); + } + + private AwsCredentialsIdentity resolveFromSourceProfile(String profileName, Set visited) { + if (!visited.add(profileName)) { + throw new IllegalStateException("Circular source_profile reference detected: " + visited); + } else if (profileFile == null) { + throw new IllegalStateException("No profile file available for source_profile resolution"); + } + + AwsProfile sourceProfile = profileFile.profile(profileName); + if (sourceProfile == null) { + throw new IllegalStateException("Source profile '" + profileName + "' not found"); + } + + // Per the Assume Role SEP: terminate at static credentials + for (AwsConfigCredentialSource src : sourceProfile.credentialSources()) { + if (src instanceof AwsConfigCredentialSource.StaticKeys(String accessKeyId, String secretAccessKey, String accountId)) { + return AwsCredentialsIdentity.create( + accessKeyId, + secretAccessKey, + null, + null, + accountId); + } else if (src instanceof AwsConfigCredentialSource.SessionKeys(String accessKeyId, String secretAccessKey, String sessionToken, String accountId)) { + return AwsCredentialsIdentity.create( + accessKeyId, + secretAccessKey, + sessionToken, + null, + accountId); + } else if (src instanceof AwsConfigCredentialSource.AssumeRole nested) { + // Recursive: resolve source creds for the nested role, then assume it + AwsCredentialsIdentity nestedSource = resolveSourceCredentials(nested, visited); + return callAssumeRole(nestedSource, nested.roleArn(), nested.externalId()).unwrap(); + } + } + + throw new IllegalStateException("Source profile '" + profileName + "' has no resolvable credential source"); + } + + private AwsCredentialsIdentity resolveFromCredentialSource(String credentialSource) { + return switch (credentialSource) { + case "Environment" -> { + String ak = getRequireEnv("AWS_ACCESS_KEY_ID"); + String sk = getRequireEnv("AWS_SECRET_ACCESS_KEY"); + String st = System.getenv("AWS_SESSION_TOKEN"); + yield AwsCredentialsIdentity.create(ak, sk, st, null, System.getenv("AWS_ACCOUNT_ID")); + } + case "Ec2InstanceMetadata" -> { + // Create a temporary ChainSetup to let ImdsCredentialProvider register its resolver + var tempSetup = ChainSetup.builder().build(); + new ImdsCredentialProvider().setup(AwsCredentialsIdentity.class, tempSetup); + var resolvers = tempSetup.resolvers(); + if (resolvers.isEmpty()) { + throw new IllegalStateException("IMDS credential provider did not produce a resolver"); + } + @SuppressWarnings("unchecked") + var r = (IdentityResolver) resolvers.getFirst().resolver(); + yield r.resolveIdentity(Context.create()).unwrap(); + } + default -> throw new IllegalStateException("Unsupported credential_source: " + credentialSource); + }; + } + + private static String getRequireEnv(String name) { + var result = System.getenv(name); + if (result == null) { + throw new IllegalStateException("credential_source=Environment but " + name + " not set"); + } + return result; + } + + private IdentityResult callAssumeRole( + AwsCredentialsIdentity sourceCredentials, + String roleArn, + String externalId + ) { + // Create a static resolver for the source credentials + var sourceResolver = createSourceResolver(sourceCredentials); + + try (DynamicClient client = StsClientFactory.create(sourceResolver)) { + Map input = Map.of( + "RoleArn", + roleArn, + "RoleSessionName", + "smithy-java-" + System.currentTimeMillis(), + "ExternalId", + externalId); + return StsWebIdentityResolver.parseCredentials(client.call("AssumeRole", input)); + } + } + + private static IdentityResolver createSourceResolver(AwsCredentialsIdentity creds) { + IdentityResult sourceResult = IdentityResult.of(creds); + return new IdentityResolver<>() { + @Override + public IdentityResult resolveIdentity(Context c) { + return sourceResult; + } + + @Override + public Class identityType() { + return AwsCredentialsIdentity.class; + } + }; + } +} diff --git a/aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/StsClientFactory.java b/aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/StsClientFactory.java new file mode 100644 index 000000000..1ea92eb78 --- /dev/null +++ b/aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/StsClientFactory.java @@ -0,0 +1,69 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.sts; + +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.client.awsquery.AwsQueryClientProtocol; +import software.amazon.smithy.java.client.core.auth.scheme.AuthSchemeResolver; +import software.amazon.smithy.java.dynamicclient.DynamicClient; +import software.amazon.smithy.java.endpoints.EndpointResolver; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.ShapeId; + +/** + * Creates STS dynamic clients for credential resolution. + * + *

Uses the bundled STS Smithy model. Endpoint resolution is handled by the + * auto-discovered EndpointRulesPlugin. + */ +final class StsClientFactory { + + static final ShapeId STS_SERVICE = ShapeId.from("com.amazonaws.sts#AWSSecurityTokenServiceV20110615"); + + private static final String STS_VERSION = "2011-06-15"; + private static final String DEFAULT_ENDPOINT = "https://sts.amazonaws.com"; + + /** + * Creates an STS client configured with the given source credentials. + */ + static DynamicClient create(IdentityResolver sourceCredentials) { + return DynamicClient.builder() + .model(model()) + .serviceId(STS_SERVICE) + .protocol(new AwsQueryClientProtocol(STS_SERVICE, STS_VERSION)) + .addIdentityResolver(sourceCredentials) + .endpointResolver(EndpointResolver.staticEndpoint(DEFAULT_ENDPOINT)) + .build(); + } + + /** + * Creates an STS client with no auth (for operations like AssumeRoleWithWebIdentity + * where the token itself is the authentication). + */ + static DynamicClient createNoAuth() { + return DynamicClient.builder() + .model(model()) + .serviceId(STS_SERVICE) + .protocol(new AwsQueryClientProtocol(STS_SERVICE, STS_VERSION)) + .authSchemeResolver(AuthSchemeResolver.NO_AUTH) + .endpointResolver(EndpointResolver.staticEndpoint(DEFAULT_ENDPOINT)) + .build(); + } + + static Model model() { + return ModelHolder.MODEL; + } + + private static final class ModelHolder { + static final Model MODEL = Model.assembler() + .discoverModels(StsClientFactory.class.getClassLoader()) + .assemble() + .unwrap(); + } + + private StsClientFactory() {} +} diff --git a/aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/StsWebIdentityResolver.java b/aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/StsWebIdentityResolver.java new file mode 100644 index 000000000..193f07679 --- /dev/null +++ b/aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/StsWebIdentityResolver.java @@ -0,0 +1,99 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.sts; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.Map; +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; +import software.amazon.smithy.java.auth.api.identity.IdentityResult; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.config.AwsConfigCredentialSource; +import software.amazon.smithy.java.context.Context; +import software.amazon.smithy.java.core.serde.document.Document; +import software.amazon.smithy.java.dynamicclient.DynamicClient; + +/** + * Resolver that calls STS AssumeRoleWithWebIdentity using a token file and role ARN. + * + *

This operation does not require source credentials (the web identity token is the + * authentication), so the STS client is configured with no auth. + */ +final class StsWebIdentityResolver implements IdentityResolver { + + private final AwsConfigCredentialSource.WebIdentityToken source; + private final DynamicClient client; + + StsWebIdentityResolver(AwsConfigCredentialSource.WebIdentityToken source, DynamicClient client) { + this.source = source; + this.client = client; + } + + @Override + public Class identityType() { + return AwsCredentialsIdentity.class; + } + + @Override + public IdentityResult resolveIdentity(Context ctx) { + try { + var token = Files.readString(Path.of(source.webIdentityTokenFile()), StandardCharsets.UTF_8).trim(); + var sessionName = source.roleSessionName() != null + ? source.roleSessionName() + : "smithy-java-" + System.currentTimeMillis(); + + return parseCredentials(client.call("AssumeRoleWithWebIdentity", + Map.of( + "RoleArn", + source.roleArn(), + "RoleSessionName", + sessionName, + "WebIdentityToken", + token))); + } catch (RuntimeException | IOException e) { + var msg = String.format("Failed to assume role with web identity (role=%s): %s", + source.roleArn(), + e.getMessage()); + throw new RuntimeException(msg, e); + } + } + + static IdentityResult parseCredentials(Document output) { + Document creds = output.getMember("Credentials"); + String accessKeyId = creds.getMember("AccessKeyId").asString(); + String secretAccessKey = creds.getMember("SecretAccessKey").asString(); + String sessionToken = creds.getMember("SessionToken").asString(); + Instant expiration = Instant.parse(creds.getMember("Expiration").asString()); + + String accountId = null; + Document assumedRoleUser = output.getMember("AssumedRoleUser"); + if (assumedRoleUser != null) { + accountId = parseAccountId(assumedRoleUser); + } + + return IdentityResult.of(AwsCredentialsIdentity.create( + accessKeyId, + secretAccessKey, + sessionToken, + expiration, + accountId)); + } + + private static String parseAccountId(Document assumedRoleUser) { + var arn = assumedRoleUser.getMember("Arn").asString(); + if (arn != null && arn.contains(":")) { + var parts = arn.split(":"); + if (parts.length >= 5) { + return parts[4]; + } + } + + return null; + } +} diff --git a/aws/aws-credentials-sts/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider b/aws/aws-credentials-sts/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider new file mode 100644 index 000000000..f573ccea5 --- /dev/null +++ b/aws/aws-credentials-sts/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider @@ -0,0 +1,3 @@ +software.amazon.smithy.java.aws.credentials.sts.ProfileAssumeRoleProvider +software.amazon.smithy.java.aws.credentials.sts.ProfileWebIdentityProvider +software.amazon.smithy.java.aws.credentials.sts.EnvWebIdentityProvider diff --git a/aws/aws-credentials-sts/src/test/java/software/amazon/smithy/java/aws/credentials/sts/EnvWebIdentityProviderTest.java b/aws/aws-credentials-sts/src/test/java/software/amazon/smithy/java/aws/credentials/sts/EnvWebIdentityProviderTest.java new file mode 100644 index 000000000..73fce50aa --- /dev/null +++ b/aws/aws-credentials-sts/src/test/java/software/amazon/smithy/java/aws/credentials/sts/EnvWebIdentityProviderTest.java @@ -0,0 +1,87 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.sts; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.credentials.chain.ChainSetup; + +class EnvWebIdentityProviderTest { + + @Test + void registersWhenEnvVarsSet(@TempDir Path tmp) throws IOException { + Path tokenFile = tmp.resolve("token"); + Files.writeString(tokenFile, "my-web-identity-token"); + + var setup = ChainSetup.builder() + .env(Map.of( + "AWS_WEB_IDENTITY_TOKEN_FILE", + tokenFile.toString(), + "AWS_ROLE_ARN", + "arn:aws:iam::123456789:role/WebRole", + "AWS_ROLE_SESSION_NAME", + "test-session")::get) + .build(); + var provider = new EnvWebIdentityProvider(); + setup.setCurrentProvider(provider); + + provider.setup(AwsCredentialsIdentity.class, setup); + assertEquals(1, setup.resolvers().size()); + } + + @Test + void skipsWhenTokenFileMissing() { + var setup = ChainSetup.builder() + .env(Map.of("AWS_ROLE_ARN", "arn:aws:iam::123456789:role/WebRole")::get) + .build(); + var provider = new EnvWebIdentityProvider(); + setup.setCurrentProvider(provider); + + provider.setup(AwsCredentialsIdentity.class, setup); + assertEquals(0, setup.resolvers().size()); + } + + @Test + void skipsWhenRoleArnMissing(@TempDir Path tmp) throws IOException { + Path tokenFile = tmp.resolve("token"); + Files.writeString(tokenFile, "my-web-identity-token"); + + var setup = ChainSetup.builder() + .env(Map.of("AWS_WEB_IDENTITY_TOKEN_FILE", tokenFile.toString())::get) + .build(); + var provider = new EnvWebIdentityProvider(); + setup.setCurrentProvider(provider); + + provider.setup(AwsCredentialsIdentity.class, setup); + assertEquals(0, setup.resolvers().size()); + } + + @Test + void skipsForNonAwsIdentity(@TempDir Path tmp) throws IOException { + Path tokenFile = tmp.resolve("token"); + Files.writeString(tokenFile, "my-web-identity-token"); + + var setup = ChainSetup.builder() + .env(Map.of( + "AWS_WEB_IDENTITY_TOKEN_FILE", + tokenFile.toString(), + "AWS_ROLE_ARN", + "arn:aws:iam::123456789:role/WebRole")::get) + .build(); + var provider = new EnvWebIdentityProvider(); + setup.setCurrentProvider(provider); + + provider.setup(software.amazon.smithy.java.auth.api.identity.Identity.class, setup); + assertEquals(0, setup.resolvers().size()); + } +} diff --git a/aws/aws-credentials-sts/src/test/java/software/amazon/smithy/java/aws/credentials/sts/ProfileAssumeRoleProviderTest.java b/aws/aws-credentials-sts/src/test/java/software/amazon/smithy/java/aws/credentials/sts/ProfileAssumeRoleProviderTest.java new file mode 100644 index 000000000..b17fe52b9 --- /dev/null +++ b/aws/aws-credentials-sts/src/test/java/software/amazon/smithy/java/aws/credentials/sts/ProfileAssumeRoleProviderTest.java @@ -0,0 +1,131 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.sts; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.config.AwsConfigCredentialSource; +import software.amazon.smithy.java.aws.config.AwsProfileFile; +import software.amazon.smithy.java.aws.credentials.chain.ChainSetup; +import software.amazon.smithy.java.context.Context; + +class ProfileAssumeRoleProviderTest { + + private static AwsConfigCredentialSource.AssumeRole assumeRole( + String roleArn, + String sourceProfile, + String credentialSource + ) { + return new AwsConfigCredentialSource.AssumeRole( + roleArn, + sourceProfile, + credentialSource, + null, + null, + null, + null, + null); + } + + @Test + void registersWhenProfileHasRoleArn(@TempDir Path tmp) throws IOException { + Path config = tmp.resolve("config"); + Files.writeString(config, """ + [default] + role_arn = arn:aws:iam::123456789:role/RoleA + source_profile = creds + + [profile creds] + aws_access_key_id = AK + aws_secret_access_key = SK + """); + + var profileFile = AwsProfileFile.builder().configFile(config).credentialsFile(null).build(); + var setup = ChainSetup.builder().build(); + setup.setProfileFile(profileFile); + setup.setProfile(profileFile.activeProfile()); + var provider = new ProfileAssumeRoleProvider(); + setup.setCurrentProvider(provider); + + provider.setup(AwsCredentialsIdentity.class, setup); + assertEquals(1, setup.resolvers().size()); + } + + @Test + void skipsWhenNoRoleArn(@TempDir Path tmp) throws IOException { + Path config = tmp.resolve("config"); + Files.writeString(config, """ + [default] + aws_access_key_id = AK + aws_secret_access_key = SK + """); + + var profileFile = AwsProfileFile.builder().configFile(config).credentialsFile(null).build(); + var setup = ChainSetup.builder().build(); + setup.setProfileFile(profileFile); + setup.setProfile(profileFile.activeProfile()); + var provider = new ProfileAssumeRoleProvider(); + setup.setCurrentProvider(provider); + + provider.setup(AwsCredentialsIdentity.class, setup); + assertEquals(0, setup.resolvers().size()); + } + + @Test + void detectsCircularSourceProfile(@TempDir Path tmp) throws IOException { + Path config = tmp.resolve("config"); + Files.writeString(config, """ + [default] + role_arn = arn:aws:iam::123456789:role/RoleA + source_profile = B + + [profile B] + role_arn = arn:aws:iam::123456789:role/RoleB + source_profile = default + """); + + var profileFile = AwsProfileFile.builder().configFile(config).credentialsFile(null).build(); + var source = assumeRole("arn:aws:iam::123456789:role/RoleA", "B", null); + var resolver = new StsAssumeRoleResolver(source, profileFile); + + var ex = assertThrows(RuntimeException.class, () -> resolver.resolveIdentity(Context.create())); + assertTrue(ex.getMessage().contains("Circular") || ex.getCause().getMessage().contains("Circular")); + } + + @Test + void failsWhenSourceProfileNotFound(@TempDir Path tmp) throws IOException { + Path config = tmp.resolve("config"); + Files.writeString(config, """ + [default] + role_arn = arn:aws:iam::123456789:role/RoleA + source_profile = nonexistent + """); + + var profileFile = AwsProfileFile.builder().configFile(config).credentialsFile(null).build(); + var source = assumeRole("arn:aws:iam::123456789:role/RoleA", "nonexistent", null); + var resolver = new StsAssumeRoleResolver(source, profileFile); + + var ex = assertThrows(RuntimeException.class, () -> resolver.resolveIdentity(Context.create())); + assertTrue(ex.getMessage().contains("nonexistent") || ex.getCause().getMessage().contains("nonexistent")); + } + + @Test + void failsWithUnsupportedCredentialSource() { + var source = assumeRole("arn:aws:iam::123456789:role/RoleA", null, "CustomUnsupportedProvider"); + var resolver = new StsAssumeRoleResolver(source, null); + + var ex = assertThrows(RuntimeException.class, () -> resolver.resolveIdentity(Context.create())); + assertTrue(ex.getMessage().contains("Unsupported") || ex.getCause().getMessage().contains("Unsupported")); + } +} diff --git a/aws/aws-credentials-sts/src/test/java/software/amazon/smithy/java/aws/credentials/sts/ProfileWebIdentityProviderTest.java b/aws/aws-credentials-sts/src/test/java/software/amazon/smithy/java/aws/credentials/sts/ProfileWebIdentityProviderTest.java new file mode 100644 index 000000000..90c12f559 --- /dev/null +++ b/aws/aws-credentials-sts/src/test/java/software/amazon/smithy/java/aws/credentials/sts/ProfileWebIdentityProviderTest.java @@ -0,0 +1,80 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.sts; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.config.AwsProfileFile; +import software.amazon.smithy.java.aws.credentials.chain.ChainSetup; + +class ProfileWebIdentityProviderTest { + + @Test + void registersWhenProfileHasTokenFile(@TempDir Path tmp) throws IOException { + Path config = tmp.resolve("config"); + Files.writeString(config, """ + [default] + role_arn = arn:aws:iam::123456789:role/RoleA + web_identity_token_file = /some/path + """); + + var profileFile = AwsProfileFile.builder().configFile(config).credentialsFile(null).build(); + var setup = ChainSetup.builder().build(); + setup.setProfileFile(profileFile); + setup.setProfile(profileFile.activeProfile()); + var provider = new ProfileWebIdentityProvider(); + setup.setCurrentProvider(provider); + + provider.setup(AwsCredentialsIdentity.class, setup); + assertEquals(1, setup.resolvers().size()); + } + + @Test + void skipsForNonAwsIdentity(@TempDir Path tmp) throws IOException { + Path config = tmp.resolve("config"); + Files.writeString(config, """ + [default] + role_arn = arn:aws:iam::123456789:role/RoleA + web_identity_token_file = /some/path + """); + + var profileFile = AwsProfileFile.builder().configFile(config).credentialsFile(null).build(); + var setup = ChainSetup.builder().build(); + setup.setProfileFile(profileFile); + setup.setProfile(profileFile.activeProfile()); + var provider = new ProfileWebIdentityProvider(); + setup.setCurrentProvider(provider); + + provider.setup(software.amazon.smithy.java.auth.api.identity.Identity.class, setup); + assertEquals(0, setup.resolvers().size()); + } + + @Test + void skipsWhenNoTokenFile(@TempDir Path tmp) throws IOException { + Path config = tmp.resolve("config"); + Files.writeString(config, """ + [default] + aws_access_key_id = AK + aws_secret_access_key = SK + """); + + var profileFile = AwsProfileFile.builder().configFile(config).credentialsFile(null).build(); + var setup = ChainSetup.builder().build(); + setup.setProfileFile(profileFile); + setup.setProfile(profileFile.activeProfile()); + var provider = new ProfileWebIdentityProvider(); + setup.setCurrentProvider(provider); + + provider.setup(AwsCredentialsIdentity.class, setup); + assertEquals(0, setup.resolvers().size()); + } +} diff --git a/aws/aws-credentials-sts/src/test/java/software/amazon/smithy/java/aws/credentials/sts/StsAssumeRoleResolverTest.java b/aws/aws-credentials-sts/src/test/java/software/amazon/smithy/java/aws/credentials/sts/StsAssumeRoleResolverTest.java new file mode 100644 index 000000000..b0ccaf600 --- /dev/null +++ b/aws/aws-credentials-sts/src/test/java/software/amazon/smithy/java/aws/credentials/sts/StsAssumeRoleResolverTest.java @@ -0,0 +1,194 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.sts; + +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import software.amazon.smithy.java.aws.config.AwsConfigCredentialSource; +import software.amazon.smithy.java.aws.config.AwsProfileFile; +import software.amazon.smithy.java.context.Context; + +class StsAssumeRoleResolverTest { + + private static AwsConfigCredentialSource.AssumeRole assumeRole( + String roleArn, + String sourceProfile, + String credentialSource + ) { + return new AwsConfigCredentialSource.AssumeRole( + roleArn, + sourceProfile, + credentialSource, + null, + null, + null, + null, + null); + } + + @Test + void resolvesSourceCredsAndAttemptsStsCall(@TempDir Path tmp) throws IOException { + Path config = tmp.resolve("config"); + Files.writeString(config, """ + [default] + role_arn = arn:aws:iam::123456789:role/RoleA + source_profile = src + + [profile src] + aws_access_key_id = SOURCE_AK + aws_secret_access_key = SOURCE_SK + """); + + var profileFile = AwsProfileFile.builder().configFile(config).credentialsFile(null).build(); + var source = assumeRole("arn:aws:iam::123456789:role/RoleA", "src", null); + var resolver = new StsAssumeRoleResolver(source, profileFile); + + // Source creds resolve, STS call fails (no real endpoint) — that's expected + assertThrows(RuntimeException.class, () -> resolver.resolveIdentity(Context.create())); + } + + @Test + void resolvesSessionKeysFromSourceProfile(@TempDir Path tmp) throws IOException { + Path config = tmp.resolve("config"); + Files.writeString(config, """ + [default] + role_arn = arn:aws:iam::123456789:role/RoleA + source_profile = src + + [profile src] + aws_access_key_id = AK + aws_secret_access_key = SK + aws_session_token = TOK + """); + + var profileFile = AwsProfileFile.builder().configFile(config).credentialsFile(null).build(); + var source = assumeRole("arn:aws:iam::123456789:role/RoleA", "src", null); + var resolver = new StsAssumeRoleResolver(source, profileFile); + + // Session keys resolve, STS call fails — expected + assertThrows(RuntimeException.class, () -> resolver.resolveIdentity(Context.create())); + } + + @Test + void detectsCircularSourceProfile(@TempDir Path tmp) throws IOException { + Path config = tmp.resolve("config"); + Files.writeString(config, """ + [default] + role_arn = arn:aws:iam::123456789:role/RoleA + source_profile = B + + [profile B] + role_arn = arn:aws:iam::123456789:role/RoleB + source_profile = default + """); + + var profileFile = AwsProfileFile.builder().configFile(config).credentialsFile(null).build(); + var source = assumeRole("arn:aws:iam::123456789:role/RoleA", "B", null); + var resolver = new StsAssumeRoleResolver(source, profileFile); + + assertThrows(RuntimeException.class, () -> resolver.resolveIdentity(Context.create())); + } + + @Test + void failsWhenSourceProfileNotFound(@TempDir Path tmp) throws IOException { + Path config = tmp.resolve("config"); + Files.writeString(config, """ + [default] + role_arn = arn:aws:iam::123456789:role/RoleA + source_profile = nonexistent + """); + + var profileFile = AwsProfileFile.builder().configFile(config).credentialsFile(null).build(); + var source = assumeRole("arn:aws:iam::123456789:role/RoleA", "nonexistent", null); + var resolver = new StsAssumeRoleResolver(source, profileFile); + + assertThrows(RuntimeException.class, () -> resolver.resolveIdentity(Context.create())); + } + + @Test + void failsWithUnsupportedCredentialSource() { + var source = assumeRole("arn:aws:iam::123456789:role/RoleA", null, "CustomUnsupportedProvider"); + var resolver = new StsAssumeRoleResolver(source, null); + + assertThrows(RuntimeException.class, () -> resolver.resolveIdentity(Context.create())); + } + + @Test + void failsWhenNeitherSourceProfileNorCredentialSource() { + var source = assumeRole("arn:aws:iam::123456789:role/RoleA", null, null); + var resolver = new StsAssumeRoleResolver(source, null); + + assertThrows(RuntimeException.class, () -> resolver.resolveIdentity(Context.create())); + } + + @Test + void failsWhenSourceProfileHasNoCredentialSources(@TempDir Path tmp) throws IOException { + Path config = tmp.resolve("config"); + Files.writeString(config, """ + [default] + role_arn = arn:aws:iam::123456789:role/RoleA + source_profile = empty + + [profile empty] + region = us-east-1 + """); + + var profileFile = AwsProfileFile.builder().configFile(config).credentialsFile(null).build(); + var source = assumeRole("arn:aws:iam::123456789:role/RoleA", "empty", null); + var resolver = new StsAssumeRoleResolver(source, profileFile); + + assertThrows(RuntimeException.class, () -> resolver.resolveIdentity(Context.create())); + } + + @Test + void failsWhenProfileFileIsNull() { + var source = assumeRole("arn:aws:iam::123456789:role/RoleA", "src", null); + var resolver = new StsAssumeRoleResolver(source, null); + + assertThrows(RuntimeException.class, () -> resolver.resolveIdentity(Context.create())); + } + + @Test + void chainedAssumeRoleResolvesNestedSourceProfile(@TempDir Path tmp) throws IOException { + Path config = tmp.resolve("config"); + Files.writeString(config, """ + [default] + role_arn = arn:aws:iam::111:role/RoleA + source_profile = B + + [profile B] + role_arn = arn:aws:iam::222:role/RoleB + source_profile = C + + [profile C] + aws_access_key_id = LEAF_AK + aws_secret_access_key = LEAF_SK + """); + + var profileFile = AwsProfileFile.builder().configFile(config).credentialsFile(null).build(); + var source = assumeRole("arn:aws:iam::111:role/RoleA", "B", null); + var resolver = new StsAssumeRoleResolver(source, profileFile); + + // Walks A -> B -> C (static keys), then attempts STS calls which fail + assertThrows(RuntimeException.class, () -> resolver.resolveIdentity(Context.create())); + } + + @Test + void credentialSourceEnvironmentResolvesAndAttemptsSts(@TempDir Path tmp) throws IOException { + // This test requires real env vars — will fail if AWS_ACCESS_KEY_ID not set + // We test the error path (env vars not set) + var source = assumeRole("arn:aws:iam::123456789:role/RoleA", null, "Environment"); + var resolver = new StsAssumeRoleResolver(source, null); + + // Fails because AWS_ACCESS_KEY_ID is not set in test environment + assertThrows(RuntimeException.class, () -> resolver.resolveIdentity(Context.create())); + } +} diff --git a/aws/aws-credentials-sts/src/test/java/software/amazon/smithy/java/aws/credentials/sts/StsResponseParsingTest.java b/aws/aws-credentials-sts/src/test/java/software/amazon/smithy/java/aws/credentials/sts/StsResponseParsingTest.java new file mode 100644 index 000000000..a8b2bff02 --- /dev/null +++ b/aws/aws-credentials-sts/src/test/java/software/amazon/smithy/java/aws/credentials/sts/StsResponseParsingTest.java @@ -0,0 +1,69 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.sts; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.java.core.serde.document.Document; + +class StsResponseParsingTest { + + @Test + void parseCredentialsExtractsAllFields() { + var creds = Document.of(Map.of( + "AccessKeyId", + Document.of("AKID"), + "SecretAccessKey", + Document.of("SECRET"), + "SessionToken", + Document.of("TOKEN"), + "Expiration", + Document.of("2099-01-01T00:00:00Z"))); + var assumedRoleUser = Document.of(Map.of( + "AssumedRoleId", + Document.of("role-id"), + "Arn", + Document.of("arn:aws:sts::123456789012:assumed-role/RoleA/session"))); + var output = Document.of(Map.of( + "Credentials", + creds, + "AssumedRoleUser", + assumedRoleUser)); + + var result = StsWebIdentityResolver.parseCredentials(output); + var id = result.unwrap(); + assertEquals("AKID", id.accessKeyId()); + assertEquals("SECRET", id.secretAccessKey()); + assertEquals("TOKEN", id.sessionToken()); + assertEquals("123456789012", id.accountId()); + assertNotNull(id.expirationTime()); + } + + @Test + void parseCredentialsHandlesMissingAssumedRoleUser() { + var creds = Document.of(Map.of( + "AccessKeyId", + Document.of("AK"), + "SecretAccessKey", + Document.of("SK"), + "SessionToken", + Document.of("TOK"), + "Expiration", + Document.of("2099-06-01T00:00:00Z"))); + var output = Document.of(Map.of("Credentials", creds)); + + var result = StsWebIdentityResolver.parseCredentials(output); + var id = result.unwrap(); + assertEquals("AK", id.accessKeyId()); + assertEquals("SK", id.secretAccessKey()); + assertEquals("TOK", id.sessionToken()); + assertNull(id.accountId()); + } +} diff --git a/aws/aws-credentials-sts/src/test/java/software/amazon/smithy/java/aws/credentials/sts/StsWebIdentityResolverTest.java b/aws/aws-credentials-sts/src/test/java/software/amazon/smithy/java/aws/credentials/sts/StsWebIdentityResolverTest.java new file mode 100644 index 000000000..34476f0ae --- /dev/null +++ b/aws/aws-credentials-sts/src/test/java/software/amazon/smithy/java/aws/credentials/sts/StsWebIdentityResolverTest.java @@ -0,0 +1,52 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.sts; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import software.amazon.smithy.java.aws.config.AwsConfigCredentialSource; +import software.amazon.smithy.java.context.Context; + +class StsWebIdentityResolverTest { + + @Test + void failsWhenTokenFileDoesNotExist() { + var source = new AwsConfigCredentialSource.WebIdentityToken( + "arn:aws:iam::123:role/R", + "/nonexistent/path/token", + "session", + null); + var resolver = new StsWebIdentityResolver(source, StsClientFactory.createNoAuth()); + + var ex = assertThrows(RuntimeException.class, () -> resolver.resolveIdentity(Context.create())); + assertTrue(ex.getMessage().contains("Failed to assume role with web identity")); + } + + @Test + void readsTokenFileAndAttemptsStsCall(@TempDir Path tmp) throws IOException { + Path tokenFile = tmp.resolve("token"); + Files.writeString(tokenFile, "my-oidc-token-value"); + + var source = new AwsConfigCredentialSource.WebIdentityToken( + "arn:aws:iam::123:role/R", + tokenFile.toString(), + "my-session", + null); + var resolver = new StsWebIdentityResolver(source, StsClientFactory.createNoAuth()); + + // Will fail at the HTTP call (no real STS endpoint), but verifies token was read + // and the call was attempted with correct parameters + var ex = assertThrows(RuntimeException.class, () -> resolver.resolveIdentity(Context.create())); + assertTrue(ex.getMessage().contains("Failed to assume role with web identity")); + assertTrue(ex.getMessage().contains("arn:aws:iam::123:role/R")); + } +} diff --git a/aws/client/aws-client-core/build.gradle.kts b/aws/client/aws-client-core/build.gradle.kts index 9d388006b..4a89e0f3d 100644 --- a/aws/client/aws-client-core/build.gradle.kts +++ b/aws/client/aws-client-core/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { api(project(":client:client-core")) api(project(":aws:aws-auth-api")) api(project(":auth-api")) + implementation(project(":aws:aws-credential-chain")) } tasks { diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/AwsCredentialChainPlugin.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/AwsCredentialChainPlugin.java new file mode 100644 index 000000000..df5aa794d --- /dev/null +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/AwsCredentialChainPlugin.java @@ -0,0 +1,64 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.client.core; + +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.credentials.chain.CredentialChain; +import software.amazon.smithy.java.client.core.ClientConfig; +import software.amazon.smithy.java.client.core.ClientPlugin; +import software.amazon.smithy.java.client.core.auth.scheme.AuthScheme; + +/** + * A {@link ClientPlugin} that registers the AWS default credential chain on any client that uses an AWS auth scheme + * (one whose {@link AuthScheme#identityClass()} is {@link AwsCredentialsIdentity}). + * + *

This plugin is wired into generated AWS clients by codegen. It is a no-op for clients that do not use + * AWS authentication or that already have an {@link AwsCredentialsIdentity} resolver registered. + * + *

Users can also add it explicitly: + *

{@code
+ * MyClient.builder()
+ *     .addPlugin(new AwsCredentialChainPlugin())
+ *     .build();
+ * }
+ * + *

To customize the chain (e.g., exclude providers), build the chain manually and register it as an identity + * resolver directly instead of using this plugin. + */ +public final class AwsCredentialChainPlugin implements ClientPlugin { + @Override + public Phase getPluginPhase() { + return Phase.DEFAULTS; + } + + @Override + public void configureClient(ClientConfig.Builder config) { + if (needsAwsCredentials(config) && !hasAwsCredentialsResolver(config)) { + var chain = CredentialChain.create(AwsCredentialsIdentity.class); + config.addIdentityResolver(chain); + config.addInterceptor(new InvalidateOnAuthFailureInterceptor(chain)); + } + } + + private static boolean needsAwsCredentials(ClientConfig.Builder config) { + for (AuthScheme scheme : config.supportedAuthSchemes()) { + if (scheme.identityClass() == AwsCredentialsIdentity.class) { + return true; + } + } + return false; + } + + private static boolean hasAwsCredentialsResolver(ClientConfig.Builder config) { + for (IdentityResolver resolver : config.identityResolvers()) { + if (resolver.identityType() == AwsCredentialsIdentity.class) { + return true; + } + } + return false; + } +} diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/InvalidateOnAuthFailureInterceptor.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/InvalidateOnAuthFailureInterceptor.java new file mode 100644 index 000000000..5c794e15e --- /dev/null +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/InvalidateOnAuthFailureInterceptor.java @@ -0,0 +1,47 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.client.core; + +import java.util.Set; +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; +import software.amazon.smithy.java.client.core.interceptors.ClientInterceptor; +import software.amazon.smithy.java.client.core.interceptors.OutputHook; +import software.amazon.smithy.java.core.error.ModeledException; + +/** + * Interceptor that invalidates cached credentials when a service returns an authentication error + * (HTTP 401 or 403), forcing the next retry attempt to resolve fresh credentials. + */ +final class InvalidateOnAuthFailureInterceptor implements ClientInterceptor { + + private final IdentityResolver resolver; + + // well-known error names. Ideally the wire would have signal so we don't need this. + private static final Set EXPIRED_NAMES = Set.of( + "ExpiredToken", + "InvalidToken", + "AuthFailure"); + + InvalidateOnAuthFailureInterceptor(IdentityResolver resolver) { + this.resolver = resolver; + } + + @Override + public void readAfterAttempt(OutputHook hook, RuntimeException error) { + if (isAuthError(error)) { + resolver.invalidate(); + } + } + + private static boolean isAuthError(RuntimeException error) { + // Check for common auth failure error names. + if (error instanceof ModeledException me) { + var name = me.schema().id().getName(); + return EXPIRED_NAMES.contains(name); + } + return false; + } +} diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java new file mode 100644 index 000000000..5f96f8d22 --- /dev/null +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java @@ -0,0 +1,42 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.client.core.identity; + +import java.util.Set; +import software.amazon.smithy.java.auth.api.identity.Identity; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; +import software.amazon.smithy.java.aws.credentials.chain.ChainSetup; +import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; +import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; +import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; + +public final class EnvironmentCredentialProvider implements ChainIdentityProvider { + + private static final Set FEATURE_IDS = Set.of(new CredentialFeatureId("g")); + + @Override + public String name() { + return "Environment"; + } + + @Override + public OrderingConstraint ordering() { + return new OrderingConstraint.Standard(StandardProvider.ENVIRONMENT); + } + + @Override + public Set featureIds() { + return FEATURE_IDS; + } + + @Override + public void setup(Class identityType, ChainSetup setup) { + if (identityType == AwsCredentialsIdentity.class) { + setup.addTerminalResolver(EnvironmentVariableIdentityResolver.INSTANCE); + } + } +} diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentVariableIdentityResolver.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentVariableIdentityResolver.java index 93b788411..a9419f644 100644 --- a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentVariableIdentityResolver.java +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentVariableIdentityResolver.java @@ -13,8 +13,10 @@ /** * {@link AwsCredentialsResolver} implementation that loads credentials from environment variables. * - *

This resolvers expects the following environment variables to be present in order to resolve an - * {@link AwsCredentialsIdentity}: + *

This resolver reads environment variables once on first access and caches the result. Use + * {@link #invalidate()} to force re-reading (e.g., in tests). + * + *

Expected environment variables: *

*
{@code AWS_ACCESS_KEY_ID}
*
Sets the AWS Access Key for the identity
@@ -22,6 +24,8 @@ *
Sets the AWS Secret Key for the identity
*
{@code AWS_SESSION_TOKEN}
*
(optional) Security token provided by the AWS Security Token Service (STS) for temporary credentials
+ *
{@code AWS_ACCOUNT_ID}
+ *
(optional) AWS account ID
*
*/ public final class EnvironmentVariableIdentityResolver implements AwsCredentialsResolver { @@ -30,19 +34,40 @@ public final class EnvironmentVariableIdentityResolver implements AwsCredentials private static final String ACCESS_KEY_PROPERTY = "AWS_ACCESS_KEY_ID"; private static final String SECRET_KEY_PROPERTY = "AWS_SECRET_ACCESS_KEY"; private static final String SESSION_TOKEN_PROPERTY = "AWS_SESSION_TOKEN"; - private static final String ERROR_MESSAGE = "Could not resolve an AWS identity using the AWS_ACCESS_KEY_ID and " - + "AWS_SECRET_ACCESS_KEY environment variables"; + private static final String ACCOUNT_ID_PROPERTY = "AWS_ACCOUNT_ID"; + private static final IdentityResult NOT_FOUND = IdentityResult.ofError( + EnvironmentVariableIdentityResolver.class, + "Could not resolve an AWS identity using the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment " + + "variables"); + + private volatile IdentityResult cached; @Override public IdentityResult resolveIdentity(Context requestProperties) { + IdentityResult result = cached; + if (result != null) { + return result; + } + + result = resolve(); + cached = result; + return result; + } + + @Override + public void invalidate() { + cached = null; + } + + private static IdentityResult resolve() { String accessKey = System.getenv(ACCESS_KEY_PROPERTY); String secretKey = System.getenv(SECRET_KEY_PROPERTY); - String sessionToken = System.getenv(SESSION_TOKEN_PROPERTY); - if (accessKey == null || secretKey == null) { - return IdentityResult.ofError(getClass(), ERROR_MESSAGE); + return NOT_FOUND; } - return IdentityResult.of(AwsCredentialsIdentity.create(accessKey, secretKey, sessionToken)); + String sessionToken = System.getenv(SESSION_TOKEN_PROPERTY); + String accountId = System.getenv(ACCOUNT_ID_PROPERTY); + return IdentityResult.of(AwsCredentialsIdentity.create(accessKey, secretKey, sessionToken, null, accountId)); } } diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java new file mode 100644 index 000000000..5594b9fcc --- /dev/null +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java @@ -0,0 +1,42 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.client.core.identity; + +import java.util.Set; +import software.amazon.smithy.java.auth.api.identity.Identity; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; +import software.amazon.smithy.java.aws.credentials.chain.ChainSetup; +import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; +import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; +import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; + +public final class SystemPropertiesCredentialProvider implements ChainIdentityProvider { + + private static final Set FEATURE_IDS = Set.of(new CredentialFeatureId("f")); + + @Override + public String name() { + return "JavaSystemProperties"; + } + + @Override + public OrderingConstraint ordering() { + return new OrderingConstraint.Standard(StandardProvider.JAVA_SYSTEM_PROPERTIES); + } + + @Override + public Set featureIds() { + return FEATURE_IDS; + } + + @Override + public void setup(Class identityType, ChainSetup setup) { + if (identityType == AwsCredentialsIdentity.class) { + setup.addTerminalResolver(SystemPropertiesIdentityResolver.INSTANCE); + } + } +} diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesIdentityResolver.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesIdentityResolver.java index 59e3ddbca..c6b4008ac 100644 --- a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesIdentityResolver.java +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesIdentityResolver.java @@ -13,8 +13,10 @@ /** * {@link AwsCredentialsResolver} implementation that loads credentials from Java system properties. * - *

This resolvers expects the following system properties to be present in order to resolve an - * {@link AwsCredentialsIdentity}: + *

This resolver reads system properties once on first access and caches the result. Use + * {@link #invalidate()} to force re-reading (e.g., in tests). + * + *

Expected system properties: *

*
{@code aws.accessKeyId}
*
Sets the AWS Access Key for the identity
@@ -22,6 +24,8 @@ *
Sets the AWS Secret Key for the identity
*
{@code aws.sessionToken}
*
(optional) Security token provided by the AWS Security Token Service (STS) for temporary credentials
+ *
{@code aws.accountId}
+ *
(optional) AWS account ID
*
* * @see Java System Properties @@ -32,19 +36,39 @@ public final class SystemPropertiesIdentityResolver implements AwsCredentialsRes private static final String ACCESS_KEY_PROPERTY = "aws.accessKeyId"; private static final String SECRET_KEY_PROPERTY = "aws.secretAccessKey"; private static final String SESSION_TOKEN_PROPERTY = "aws.sessionToken"; - private static final String ERROR_MESSAGE = "Could not resolve AWS identity from the aws.accessKeyId and " - + "aws.secretAccessKey system properties"; + private static final String ACCOUNT_ID_PROPERTY = "aws.accountId"; + private static final IdentityResult NOT_FOUND = IdentityResult.ofError( + SystemPropertiesIdentityResolver.class, + "Could not resolve AWS identity from the aws.accessKeyId and aws.secretAccessKey system properties"); + + private volatile IdentityResult cached; @Override public IdentityResult resolveIdentity(Context requestProperties) { + IdentityResult result = cached; + if (result != null) { + return result; + } + + result = resolve(); + cached = result; + return result; + } + + @Override + public void invalidate() { + cached = null; + } + + private static IdentityResult resolve() { String accessKey = System.getProperty(ACCESS_KEY_PROPERTY); String secretKey = System.getProperty(SECRET_KEY_PROPERTY); - String sessionToken = System.getProperty(SESSION_TOKEN_PROPERTY); - - if (accessKey != null && secretKey != null) { - return IdentityResult.of(AwsCredentialsIdentity.create(accessKey, secretKey, sessionToken)); + if (accessKey == null || secretKey == null) { + return NOT_FOUND; } - return IdentityResult.ofError(getClass(), ERROR_MESSAGE); + String sessionToken = System.getProperty(SESSION_TOKEN_PROPERTY); + String accountId = System.getProperty(ACCOUNT_ID_PROPERTY); + return IdentityResult.of(AwsCredentialsIdentity.create(accessKey, secretKey, sessionToken, null, accountId)); } } diff --git a/aws/client/aws-client-core/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider b/aws/client/aws-client-core/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider new file mode 100644 index 000000000..633bd6170 --- /dev/null +++ b/aws/client/aws-client-core/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider @@ -0,0 +1,2 @@ +software.amazon.smithy.java.aws.client.core.identity.EnvironmentCredentialProvider +software.amazon.smithy.java.aws.client.core.identity.SystemPropertiesCredentialProvider diff --git a/aws/client/aws-client-core/src/test/java/software/amazon/smithy/java/aws/client/core/InvalidateOnAuthFailureInterceptorTest.java b/aws/client/aws-client-core/src/test/java/software/amazon/smithy/java/aws/client/core/InvalidateOnAuthFailureInterceptorTest.java new file mode 100644 index 000000000..a35d92c16 --- /dev/null +++ b/aws/client/aws-client-core/src/test/java/software/amazon/smithy/java/aws/client/core/InvalidateOnAuthFailureInterceptorTest.java @@ -0,0 +1,93 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.client.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; +import software.amazon.smithy.java.auth.api.identity.IdentityResult; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.context.Context; +import software.amazon.smithy.java.core.error.ModeledException; +import software.amazon.smithy.java.core.schema.Schema; +import software.amazon.smithy.java.core.serde.ShapeSerializer; +import software.amazon.smithy.model.shapes.ShapeId; + +class InvalidateOnAuthFailureInterceptorTest { + + @Test + void invalidatesOnExpiredToken() { + var counter = new CountingResolver(); + var interceptor = new InvalidateOnAuthFailureInterceptor(counter); + + interceptor.readAfterAttempt(null, authError("ExpiredToken")); + assertEquals(1, counter.invalidateCount.get()); + } + + @Test + void invalidatesOnAuthFailure() { + var counter = new CountingResolver(); + var interceptor = new InvalidateOnAuthFailureInterceptor(counter); + + interceptor.readAfterAttempt(null, authError("AuthFailure")); + assertEquals(1, counter.invalidateCount.get()); + } + + @Test + void doesNotInvalidateOnNonAuthError() { + var counter = new CountingResolver(); + var interceptor = new InvalidateOnAuthFailureInterceptor(counter); + + interceptor.readAfterAttempt(null, new RuntimeException("network error")); + assertEquals(0, counter.invalidateCount.get()); + } + + @Test + void doesNotInvalidateOnNull() { + var counter = new CountingResolver(); + var interceptor = new InvalidateOnAuthFailureInterceptor(counter); + + interceptor.readAfterAttempt(null, null); + assertEquals(0, counter.invalidateCount.get()); + } + + private static RuntimeException authError(String errorName) { + Schema schema = Schema.createString(ShapeId.from("com.example#" + errorName)); + return new ModeledException(schema, errorName + " error") { + @Override + public void serialize(ShapeSerializer serializer) {} + + @Override + public void serializeMembers(ShapeSerializer serializer) {} + + @Override + public T getMemberValue(Schema member) { + return null; + } + }; + } + + private static class CountingResolver implements IdentityResolver { + final AtomicInteger invalidateCount = new AtomicInteger(0); + + @Override + public IdentityResult resolveIdentity(Context ctx) { + return IdentityResult.of(AwsCredentialsIdentity.create("AK", "SK")); + } + + @Override + public Class identityType() { + return AwsCredentialsIdentity.class; + } + + @Override + public void invalidate() { + invalidateCount.incrementAndGet(); + } + } +} diff --git a/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/client/integrations/aws/AwsCredentialChainIntegration.java b/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/client/integrations/aws/AwsCredentialChainIntegration.java new file mode 100644 index 000000000..f11e40002 --- /dev/null +++ b/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/client/integrations/aws/AwsCredentialChainIntegration.java @@ -0,0 +1,38 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.codegen.client.integrations.aws; + +import software.amazon.smithy.java.codegen.CodeGenerationContext; +import software.amazon.smithy.java.codegen.JavaCodegenIntegration; +import software.amazon.smithy.java.codegen.JavaCodegenSettings; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.knowledge.ServiceIndex; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.utils.SmithyInternalApi; + +/** + * Codegen integration that adds {@code AwsCredentialChainPlugin} as a default plugin for any + * service that uses an AWS auth scheme (sigv4 or sigv4a). + */ +@SmithyInternalApi +public final class AwsCredentialChainIntegration implements JavaCodegenIntegration { + + private static final String PLUGIN_CLASS = "software.amazon.smithy.java.aws.client.core.AwsCredentialChainPlugin"; + private static final ShapeId SIGV4 = ShapeId.from("aws.auth#sigv4"); + private static final ShapeId SIGV4A = ShapeId.from("aws.auth#sigv4a"); + + @Override + public void customize(CodeGenerationContext context) { + Model model = context.model(); + JavaCodegenSettings settings = context.settings(); + var service = model.expectShape(settings.service(), ServiceShape.class); + var authSchemes = ServiceIndex.of(model).getEffectiveAuthSchemes(service); + if (authSchemes.containsKey(SIGV4) || authSchemes.containsKey(SIGV4A)) { + settings.addDefaultPlugin(PLUGIN_CLASS); + } + } +} diff --git a/codegen/codegen-plugin/src/main/resources/META-INF/services/software.amazon.smithy.java.codegen.JavaCodegenIntegration b/codegen/codegen-plugin/src/main/resources/META-INF/services/software.amazon.smithy.java.codegen.JavaCodegenIntegration index c820c760d..7f77ed5dd 100644 --- a/codegen/codegen-plugin/src/main/resources/META-INF/services/software.amazon.smithy.java.codegen.JavaCodegenIntegration +++ b/codegen/codegen-plugin/src/main/resources/META-INF/services/software.amazon.smithy.java.codegen.JavaCodegenIntegration @@ -1 +1,2 @@ software.amazon.smithy.java.codegen.client.integrations.javadoc.ClientJavadocExamplesIntegration +software.amazon.smithy.java.codegen.client.integrations.aws.AwsCredentialChainIntegration diff --git a/settings.gradle.kts b/settings.gradle.kts index 5c86e4f7a..9e15b7c79 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -86,6 +86,10 @@ include(":aws:client:aws-client-rulesengine") include(":aws:integrations:aws-lambda-endpoint") include(":aws:server:aws-server-restjson") include(":aws:aws-auth-api") +include(":aws:aws-credential-chain") +include(":aws:aws-config") +include(":aws:aws-credentials-imds") +include(":aws:aws-credentials-sts") // AWS service bundling code include(":aws:aws-service-bundle")