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-config/build.gradle.kts b/aws/aws-config/build.gradle.kts new file mode 100644 index 000000000..ea9d061c9 --- /dev/null +++ b/aws/aws-config/build.gradle.kts @@ -0,0 +1,19 @@ +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 an AwsCredentialsResolver backed by them." + +extra["displayName"] = "Smithy :: Java :: AWS :: Config" +extra["moduleName"] = "software.amazon.smithy.java.aws.config" + +dependencies { + api(project(":aws:aws-auth-api")) + api(project(":auth-api")) + implementation(project(":logging")) + implementation(project(":codecs:json-codec", configuration = "shadow")) + implementation(project(":aws:aws-credential-chain")) + 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..50ae07eaf --- /dev/null +++ b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsConfigCredentialSource.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.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/AwsConfigCredentialSourceHandler.java b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsConfigCredentialSourceHandler.java new file mode 100644 index 000000000..98c0099b9 --- /dev/null +++ b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsConfigCredentialSourceHandler.java @@ -0,0 +1,54 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.config; + +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; + +/** + * Strategy for turning an {@link AwsConfigCredentialSource} into an {@link AwsCredentialsIdentity}. + * + *

A handler inspects a credential source and either produces a result (success or a typed error) + * or returns {@code null} to signal that it does not handle this source type. Returning {@code null} lets the + * enclosing resolver try the next handler in its chain. + * + *

Handlers are discovered via {@link java.util.ServiceLoader}. Modules that provide handlers register them in + * {@code META-INF/services/software.amazon.smithy.java.aws.config.AwsConfigCredentialSourceHandler}. The resolver + * iterates the profile's credential sources in priority order (as defined by {@link AwsProfile#credentialSources()}) + * and, for each source, tries all handlers until one returns non-null. + * + *

Handlers can also be registered explicitly via + * {@link AwsProfileCredentialsResolver.Builder#addHandler(AwsConfigCredentialSourceHandler)}, which + * takes precedence over SPI-discovered handlers. + * + *

The {@code aws-config} module ships with handlers for {@link AwsConfigCredentialSource.StaticKeys}, + * {@link AwsConfigCredentialSource.SessionKeys}, and {@link AwsConfigCredentialSource.CredentialProcess}. + * Downstream modules can supply handlers for the remaining source types (SSO, AssumeRole, web identity, login). + */ +public interface AwsConfigCredentialSourceHandler { + /** + * Attempt to resolve an identity from a credential source. + * + * @param source the source to resolve. + * @param context runtime context for resolution. + * @return the result of resolution, or {@code null} if this handler does not handle {@code source}'s type. + */ + IdentityResult tryResolve(AwsConfigCredentialSource source, ResolutionContext context); + + /** + * Information passed from the enclosing resolver to each handler invocation. + * + *

Handlers that walk {@code source_profile} chains can look the referenced profile up via + * {@link #profileFile()} and invoke a child resolution. Cycle detection is the caller's responsibility + * (the resolver maintains a visited-set while recursing). + * + * @param profileFile Entire merged config file data. + * @param profileName Profile name to use. + * @param requestProperties Context properties associated with the request. + */ + record ResolutionContext(AwsProfileFile profileFile, String profileName, Context requestProperties) {} +} 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..f629eb36f --- /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. + */ +public 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/AwsProfileCredentialsResolver.java b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsProfileCredentialsResolver.java new file mode 100644 index 000000000..55e372c86 --- /dev/null +++ b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsProfileCredentialsResolver.java @@ -0,0 +1,329 @@ +/* + * 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.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.ServiceLoader; +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.AwsConfigCredentialSourceHandler.ResolutionContext; +import software.amazon.smithy.java.context.Context; + +/** + * An {@link AwsCredentialsResolver} that reads credentials from a profile in the AWS shared + * configuration / credentials files by dispatching to a chain of + * {@link AwsConfigCredentialSourceHandler}s. + * + *

Architecture

+ * + *

Responsibilities are split so that the data model and credential-acquisition policy stay + * independent: + * + *

    + *
  • {@link AwsProfileFile} / {@link AwsProfile} own the loaded profile data. A profile exposes + * an ordered list of {@link AwsConfigCredentialSource credential sources} computed from its + * properties, in AWS SDK shared-configuration priority order (all sources a profile + * declares are returned, not only the SEP "winner").
  • + *
  • {@link AwsConfigCredentialSourceHandler}s provide the strategies for turning a given source type + * into an identity. They are plugged in at construction time and may come from other + * modules (for example, an STS-backed handler for {@link AwsConfigCredentialSource.AssumeRole}).
  • + *
  • This class walks the profile's source list in priority order. For each source, it tries + * handlers in the order they were registered; the first handler whose {@code tryResolve} + * returns non-null wins. Sources whose types no handler claims are skipped and the next + * source is attempted. If no source is claimed by any handler, an + * {@link IdentityResult#ofError(Class, String) error result} is returned so this resolver + * can itself be composed in a wider resolver chain.
  • + *
+ * + *

The module ships with handlers for {@link AwsConfigCredentialSource.StaticKeys} and + * {@link AwsConfigCredentialSource.SessionKeys}. A builder that has no handlers registered at + * {@link Builder#build()} time defaults to those two, so the out-of-the-box resolver behaves + * the same as a hand-rolled "basic + session" static credentials resolver while leaving role / + * SSO / process support pluggable. + * + *

Profile name selection

+ * + *
    + *
  1. Builder's {@code profileName}, if set.
  2. + *
  3. The {@code AWS_PROFILE} environment variable, if set and non-empty.
  4. + *
  5. The {@code AWS_DEFAULT_PROFILE} environment variable, if set and non-empty.
  6. + *
  7. The literal {@code "default"}.
  8. + *
+ * + *

{@link #refresh()} mutates the underlying {@link AwsProfileFile} in place (via + * {@link AwsProfileFile#refresh()}). Concurrent callers of {@link #resolveIdentity(Context)} + * observe the new state atomically after refresh completes. + */ +public final class AwsProfileCredentialsResolver implements AwsCredentialsResolver { + + /** Environment variable used to select the default profile name. */ + public static final String AWS_PROFILE_ENV = "AWS_PROFILE"; + + /** Legacy environment variable used to select the default profile name. */ + public static final String AWS_DEFAULT_PROFILE_ENV = "AWS_DEFAULT_PROFILE"; + + /** Profile name used when nothing else is configured. */ + public static final String DEFAULT_PROFILE_NAME = "default"; + + private final String profileName; + private final List handlers; + private final boolean ignoreUnhandledSources; + private final AwsProfileFile profileFile; + private final String sourceDescription; + private final IdentityResult profileNotFoundError; + + private AwsProfileCredentialsResolver(Builder b) { + this.profileName = b.profileName != null ? b.profileName : resolveDefaultProfileName(); + this.handlers = b.handlers.isEmpty() ? discoverHandlers() : List.copyOf(b.handlers); + this.ignoreUnhandledSources = b.ignoreUnhandledSources; + + if (b.profileFile != null) { + this.profileFile = b.profileFile; + } else { + AwsProfileFile.Builder fileBuilder = AwsProfileFile.builder(); + if (b.configFileSet) { + fileBuilder.configFile(b.configFile); + } + if (b.credentialsFileSet) { + fileBuilder.credentialsFile(b.credentialsFile); + } + this.profileFile = fileBuilder.build(); + } + + sourceDescription = describeSource(profileFile); + // Cached here since it could be returned over and over. + profileNotFoundError = IdentityResult.ofError( + getClass(), + "AWS profile '" + profileName + "' was not found in " + sourceDescription); + } + + private static List discoverHandlers() { + List found = new ArrayList<>(); + for (AwsConfigCredentialSourceHandler h : ServiceLoader.load(AwsConfigCredentialSourceHandler.class)) { + found.add(h); + } + return Collections.unmodifiableList(found); + } + + private static String describeSource(AwsProfileFile file) { + Path config = file.configFile(); + Path credentials = file.credentialsFile(); + if (config == null && credentials == null) { + return "the configured AWS profile file"; + } + + StringBuilder sb = new StringBuilder(); + if (config != null) { + sb.append(config); + } + if (credentials != null) { + if (!sb.isEmpty()) { + sb.append(" or "); + } + sb.append(credentials); + } + return sb.toString(); + } + + /** + * @return a new builder. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * @return the profile name this resolver looks up. + */ + public String profileName() { + return profileName; + } + + /** + * @return the {@link AwsProfileFile} snapshot used by this resolver. The instance is live; + * calling {@link AwsProfileFile#refresh()} on it reloads from disk. + */ + public AwsProfileFile profileFile() { + return profileFile; + } + + /** + * @return an unmodifiable, ordered view of this resolver's registered handlers. + */ + public List handlers() { + return handlers; + } + + /** + * Re-read the underlying {@link AwsProfileFile} from disk. Delegates to {@link AwsProfileFile#refresh()}, + * which mutates the file in place. + */ + public void refresh() { + profileFile.refresh(); + } + + @Override + public void invalidate() { + profileFile.refresh(); + } + + @Override + public IdentityResult resolveIdentity(Context requestProperties) { + // Access each time since it can be refreshed. + AwsProfile profile = profileFile.profile(profileName); + if (profile == null) { + return profileNotFoundError; + } + + List sources = profile.credentialSources(); + if (sources.isEmpty()) { + return IdentityResult.ofError( + getClass(), + "AWS profile '" + profileName + "' in " + sourceDescription + + " does not describe any credential source."); + } + + ResolutionContext ctx = new ResolutionContext(profileFile, profileName, requestProperties); + for (AwsConfigCredentialSource source : sources) { + IdentityResult result = tryHandlers(source, ctx); + if (result != null) { + return result; + } else if (!ignoreUnhandledSources) { + break; + } + } + + String typeName = sources.get(0).getClass().getSimpleName(); + return IdentityResult.ofError( + getClass(), + "AWS profile '" + profileName + "' requires a credential source of type '" + typeName + "', " + + "but no handler in this resolver claims it. Add an appropriate AwsConfigCredentialSourceHandler " + + "(for example, an STS or SSO-backed handler from another module)."); + } + + private IdentityResult tryHandlers( + AwsConfigCredentialSource source, + ResolutionContext ctx + ) { + for (AwsConfigCredentialSourceHandler handler : handlers) { + IdentityResult attempt = handler.tryResolve(source, ctx); + if (attempt != null) { + return attempt; + } + } + return null; + } + + private static String resolveDefaultProfileName() { + String name = System.getenv(AWS_PROFILE_ENV); + if (name != null && !name.isEmpty()) { + return name; + } + + name = System.getenv(AWS_DEFAULT_PROFILE_ENV); + if (name != null && !name.isEmpty()) { + return name; + } + + return DEFAULT_PROFILE_NAME; + } + + /** + * Builder for {@link AwsProfileCredentialsResolver}. + */ + public static final class Builder { + private String profileName; + private AwsProfileFile profileFile; + private Path configFile; + private boolean configFileSet; + private Path credentialsFile; + private boolean credentialsFileSet; + private final List handlers = new ArrayList<>(); + private boolean ignoreUnhandledSources; + + private Builder() {} + + /** + * Set the profile name to look up. If not set, the default resolution order applies + * ({@code AWS_PROFILE}, {@code AWS_DEFAULT_PROFILE}, {@code "default"}). + */ + public Builder profileName(String profileName) { + this.profileName = profileName; + return this; + } + + /** + * Use a pre-loaded {@link AwsProfileFile}. Mutually exclusive with {@link #configFile(Path)} + * and {@link #credentialsFile(Path)}. + */ + public Builder profileFile(AwsProfileFile profileFile) { + this.profileFile = Objects.requireNonNull(profileFile, "profileFile"); + this.configFile = null; + this.configFileSet = false; + this.credentialsFile = null; + this.credentialsFileSet = false; + return this; + } + + /** + * Override the config file path. Mutually exclusive with {@link #profileFile(AwsProfileFile)}. + * Pass {@code null} to explicitly disable reading a config file. + */ + public Builder configFile(Path configFile) { + this.profileFile = null; + this.configFile = configFile; + this.configFileSet = true; + return this; + } + + /** + * Override the credentials file path. Mutually exclusive with {@link #profileFile(AwsProfileFile)}. + * Pass {@code null} to explicitly disable reading a credentials file. + */ + public Builder credentialsFile(Path credentialsFile) { + this.profileFile = null; + this.credentialsFile = credentialsFile; + this.credentialsFileSet = true; + return this; + } + + /** + * Register a credential-source handler. Handlers are tried in registration order; the + * first handler that returns non-null for a given source wins. + * + *

If no handlers are registered before {@link #build()}, the resolver discovers + * handlers via {@link java.util.ServiceLoader}. Calling this method replaces ServiceLoader discovery + * entirely; only explicitly added handlers will be used. + */ + public Builder addHandler(AwsConfigCredentialSourceHandler handler) { + this.handlers.add(Objects.requireNonNull(handler, "handler")); + return this; + } + + /** + * When {@code true}, credential sources that no handler claims are skipped and the next source in priority + * order is attempted. When {@code false} (the default), an unhandled source causes an immediate error, + * matching the AWS SDK shared-configuration specification's requirement that the highest-priority source + * MUST be used. + */ + public Builder ignoreUnhandledSources(boolean ignoreUnhandledSources) { + this.ignoreUnhandledSources = ignoreUnhandledSources; + return this; + } + + /** + * Build the resolver. + */ + public AwsProfileCredentialsResolver build() { + return new AwsProfileCredentialsResolver(this); + } + } +} 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..f68a78fe5 --- /dev/null +++ b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsProfileFile.java @@ -0,0 +1,363 @@ +/* + * 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(); + } + + /** + * @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/CredentialProcessHandler.java b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/CredentialProcessHandler.java new file mode 100644 index 000000000..6516491c2 --- /dev/null +++ b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/CredentialProcessHandler.java @@ -0,0 +1,150 @@ +/* + * 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.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.concurrent.TimeUnit; +import software.amazon.smithy.java.auth.api.identity.IdentityResult; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.core.serde.document.Document; +import software.amazon.smithy.java.json.JsonCodec; +import software.amazon.smithy.java.logging.InternalLogger; + +/** + * Handles {@link AwsConfigCredentialSource.CredentialProcess} by invoking an external process and parsing its JSON + * stdout per the + * credential_process specification. + * + *

The process must write a JSON object to stdout with at minimum {@code Version} (integer 1), + * {@code AccessKeyId}, and {@code SecretAccessKey}. Optional fields: {@code SessionToken}, + * {@code Expiration} (ISO 8601), {@code AccountId}. + * + *

A non-zero exit code is treated as an error. The process's stderr is captured for the error message but is never + * logged above debug level to prevent leaking secrets. + */ +public final class CredentialProcessHandler implements AwsConfigCredentialSourceHandler { + + 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; + + public CredentialProcessHandler() {} + + @Override + public IdentityResult tryResolve( + AwsConfigCredentialSource source, + ResolutionContext context + ) { + if (!(source instanceof AwsConfigCredentialSource.CredentialProcess(String commandLine))) { + return null; + } + + try { + return execute(commandLine); + } catch (IOException | InterruptedException e) { + return IdentityResult.ofError(getClass(), "credential_process failed: " + e.getMessage()); + } + } + + private IdentityResult execute(String commandLine) + throws IOException, InterruptedException { + List cmd = buildCommand(commandLine); + Process process = new ProcessBuilder(cmd).redirectErrorStream(false).start(); + + String stdout; + String stderr; + // Use a shared buffer for stdin/stdout + byte[] buf = new byte[MAX_OUTPUT_BYTES + 1]; + try (var stdoutStream = process.getInputStream(); var stderrStream = process.getErrorStream()) { + stdout = readLimited(stdoutStream, buf); + stderr = readLimited(stderrStream, buf); + } finally { + process.destroy(); + } + + // Uses a very generous timeout of 60s. + boolean finished = process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + return IdentityResult.ofError(getClass(), "credential_process timed out after " + TIMEOUT_SECONDS + "s"); + } + + int exitCode = process.exitValue(); + if (exitCode != 0) { + // Per the SEP: stderr is accessible to the customer but must not be logged at levels above trace. + LOGGER.debug("credential_process exited with code {}", exitCode); + String msg = stderr.isBlank() ? "credential_process exited with code " + exitCode : stderr.strip(); + return IdentityResult.ofError(getClass(), msg); + } + + return parseOutput(stdout); + } + + // Choose the right shell for windows/not-windows. + 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); + } + + // Limit response size: read up to 65 bytes, but allow only 64; if 65 bytes was read, it's too much content. + 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 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(getClass(), + "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(getClass(), + "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-config/src/main/java/software/amazon/smithy/java/aws/config/ProfileCredentialProvider.java b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/ProfileCredentialProvider.java new file mode 100644 index 000000000..58f9cea99 --- /dev/null +++ b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/ProfileCredentialProvider.java @@ -0,0 +1,40 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.config; + +import software.amazon.smithy.java.auth.api.identity.CachingIdentityResolver; +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.AwsCredentialProvider; +import software.amazon.smithy.java.aws.credentials.chain.BuiltinProvider; +import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; +import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; + +/** + * Registers {@link AwsProfileCredentialsResolver} in the credential chain's + * {@link BuiltinProvider#SHARED_CONFIG} slot. + */ +public final class ProfileCredentialProvider implements AwsCredentialProvider { + @Override + public String name() { + return "SharedConfig"; + } + + @Override + public OrderingConstraint ordering() { + return new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG); + } + + @Override + public IdentityResolver create(ProviderContext context) { + AwsProfileCredentialsResolver resolver = AwsProfileCredentialsResolver.builder().build(); + // Share the loaded profile file with other providers via context. + context.properties().put(AwsProfileFile.CONTEXT_KEY, resolver.profileFile()); + return CachingIdentityResolver.builder(resolver) + .executor(context.executor()) + .build(); + } +} 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/main/java/software/amazon/smithy/java/aws/config/SessionKeysHandler.java b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/SessionKeysHandler.java new file mode 100644 index 000000000..40f2e20b2 --- /dev/null +++ b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/SessionKeysHandler.java @@ -0,0 +1,32 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.config; + +import software.amazon.smithy.java.auth.api.identity.IdentityResult; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; + +/** + * Handles {@link AwsConfigCredentialSource.SessionKeys}. + */ +public final class SessionKeysHandler implements AwsConfigCredentialSourceHandler { + + public SessionKeysHandler() {} + + @Override + public IdentityResult< + AwsCredentialsIdentity> tryResolve(AwsConfigCredentialSource source, ResolutionContext context) { + if (source instanceof AwsConfigCredentialSource.SessionKeys(String accessKeyId, String secretAccessKey, String sessionToken, String accountId)) { + return IdentityResult.of(AwsCredentialsIdentity.create( + accessKeyId, + secretAccessKey, + sessionToken, + null, // expirationTime + accountId)); + } + + return null; + } +} diff --git a/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/StaticKeysHandler.java b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/StaticKeysHandler.java new file mode 100644 index 000000000..cb164c475 --- /dev/null +++ b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/StaticKeysHandler.java @@ -0,0 +1,32 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.config; + +import software.amazon.smithy.java.auth.api.identity.IdentityResult; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; + +/** + * Handles {@link AwsConfigCredentialSource.StaticKeys}. + */ +public final class StaticKeysHandler implements AwsConfigCredentialSourceHandler { + + public StaticKeysHandler() {} + + @Override + public IdentityResult< + AwsCredentialsIdentity> tryResolve(AwsConfigCredentialSource source, ResolutionContext context) { + if (source instanceof AwsConfigCredentialSource.StaticKeys(String accessKeyId, String secretAccessKey, String accountId)) { + return IdentityResult.of(AwsCredentialsIdentity.create( + accessKeyId, + secretAccessKey, + null, // sessionToken + null, // expirationTime + accountId)); + } + + return null; + } +} diff --git a/aws/aws-config/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.config.AwsConfigCredentialSourceHandler b/aws/aws-config/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.config.AwsConfigCredentialSourceHandler new file mode 100644 index 000000000..09adcae3f --- /dev/null +++ b/aws/aws-config/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.config.AwsConfigCredentialSourceHandler @@ -0,0 +1,3 @@ +software.amazon.smithy.java.aws.config.StaticKeysHandler +software.amazon.smithy.java.aws.config.SessionKeysHandler +software.amazon.smithy.java.aws.config.CredentialProcessHandler diff --git a/aws/aws-config/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider b/aws/aws-config/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider new file mode 100644 index 000000000..5394a60a0 --- /dev/null +++ b/aws/aws-config/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider @@ -0,0 +1 @@ +software.amazon.smithy.java.aws.config.ProfileCredentialProvider 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/AwsProfileCredentialsResolverTest.java b/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/AwsProfileCredentialsResolverTest.java new file mode 100644 index 000000000..bf7fd9cb2 --- /dev/null +++ b/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/AwsProfileCredentialsResolverTest.java @@ -0,0 +1,261 @@ +/* + * 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 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.IdentityResult; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.context.Context; + +class AwsProfileCredentialsResolverTest { + + // --- Built-in handlers (static + session) -------------------------------------------------- + + @Test + void basicCredentialsWhenNoSessionTokenOrRole(@TempDir Path tmp) throws IOException { + Path creds = writeCredentials(tmp, """ + [default] + aws_access_key_id = AK + aws_secret_access_key = SK + """); + AwsCredentialsIdentity id = buildResolver(creds, "default").resolveIdentity(Context.empty()).unwrap(); + assertEquals("AK", id.accessKeyId()); + assertEquals("SK", id.secretAccessKey()); + assertNull(id.sessionToken()); + } + + @Test + void sessionCredentialsWhenSessionTokenPresent(@TempDir Path tmp) throws IOException { + Path creds = writeCredentials(tmp, """ + [default] + aws_access_key_id = AK + aws_secret_access_key = SK + aws_session_token = TOK + """); + AwsCredentialsIdentity id = buildResolver(creds, "default").resolveIdentity(Context.empty()).unwrap(); + assertEquals("TOK", id.sessionToken()); + } + + @Test + void reportsAccountIdWhenPresent(@TempDir Path tmp) throws IOException { + Path creds = writeCredentials(tmp, """ + [default] + aws_access_key_id = K + aws_secret_access_key = S + aws_account_id = 123456789012 + """); + AwsCredentialsIdentity id = buildResolver(creds, "default").resolveIdentity(Context.empty()).unwrap(); + assertEquals("123456789012", id.accountId()); + } + + // --- Handler chain semantics -------------------------------------------------------------- + + @Test + void unhandledSourceTypeYieldsTypedError(@TempDir Path tmp) throws IOException { + // Profile defines an AssumeRole source but the default resolver has no handler for it. + Path config = tmp.resolve("config"); + Files.writeString(config, """ + [profile role-profile] + role_arn = arn:aws:iam::123:role/X + source_profile = base + """, StandardCharsets.UTF_8); + + AwsProfileCredentialsResolver resolver = AwsProfileCredentialsResolver.builder() + .configFile(config) + .credentialsFile(null) + .profileName("role-profile") + .build(); + + IdentityResult result = resolver.resolveIdentity(Context.empty()); + assertNull(result.identity()); + assertNotNull(result.error()); + assertTrue(result.error().contains("AssumeRole")); + assertTrue(result.error().contains("no handler")); + assertEquals(AwsProfileCredentialsResolver.class, result.resolver()); + } + + @Test + void customHandlerChainTakesOver(@TempDir Path tmp) throws IOException { + // A bespoke handler that claims AssumeRole sources and returns a deterministic identity. + Path config = tmp.resolve("config"); + Files.writeString(config, """ + [profile role-profile] + role_arn = arn:aws:iam::123:role/X + source_profile = base + """, StandardCharsets.UTF_8); + + AwsConfigCredentialSourceHandler stubAssumeRoleHandler = (source, ctx) -> { + if (!(source instanceof AwsConfigCredentialSource.AssumeRole r)) { + return null; + } + return IdentityResult.of(AwsCredentialsIdentity.create( + "assumed-" + r.roleArn(), + "assumed-secret")); + }; + + AwsProfileCredentialsResolver resolver = AwsProfileCredentialsResolver.builder() + .configFile(config) + .credentialsFile(null) + .profileName("role-profile") + .addHandler(stubAssumeRoleHandler) + .addHandler(new StaticKeysHandler()) + .addHandler(new SessionKeysHandler()) + .build(); + + AwsCredentialsIdentity id = resolver.resolveIdentity(Context.empty()).unwrap(); + assertEquals("assumed-arn:aws:iam::123:role/X", id.accessKeyId()); + } + + @Test + void fallsThroughToNextSourceWhenFirstIsUnhandled(@TempDir Path tmp) throws IOException { + // Profile declares both role_arn and static keys. With ignoreUnhandledSources(true), + // the resolver skips the AssumeRole source (no handler) and uses the StaticKeys one. + Path config = tmp.resolve("config"); + Files.writeString(config, """ + [profile mixed] + role_arn = arn:aws:iam::123:role/X + source_profile = base + aws_access_key_id = FALLBACK_AK + aws_secret_access_key = FALLBACK_SK + """, StandardCharsets.UTF_8); + + AwsProfileCredentialsResolver resolver = AwsProfileCredentialsResolver.builder() + .configFile(config) + .credentialsFile(null) + .profileName("mixed") + .ignoreUnhandledSources(true) + .build(); + + AwsCredentialsIdentity id = resolver.resolveIdentity(Context.empty()).unwrap(); + assertEquals("FALLBACK_AK", id.accessKeyId()); + assertEquals("FALLBACK_SK", id.secretAccessKey()); + } + + @Test + void unhandledSourceFailsByDefault(@TempDir Path tmp) throws IOException { + // By default (strict SEP mode), an unhandled high-priority source is an error. + Path config = tmp.resolve("config"); + Files.writeString(config, """ + [profile mixed] + role_arn = arn:aws:iam::123:role/X + source_profile = base + aws_access_key_id = FALLBACK_AK + aws_secret_access_key = FALLBACK_SK + """, StandardCharsets.UTF_8); + + AwsProfileCredentialsResolver resolver = AwsProfileCredentialsResolver.builder() + .configFile(config) + .credentialsFile(null) + .profileName("mixed") + .build(); + + IdentityResult result = resolver.resolveIdentity(Context.empty()); + assertNull(result.identity()); + assertTrue(result.error().contains("AssumeRole")); + } + + @Test + void profileWithoutRecognizedSourcesErrors(@TempDir Path tmp) throws IOException { + // A profile that only sets region has no credential source. + Path config = tmp.resolve("config"); + Files.writeString(config, """ + [default] + region = us-east-1 + """, StandardCharsets.UTF_8); + + AwsProfileCredentialsResolver resolver = AwsProfileCredentialsResolver.builder() + .configFile(config) + .credentialsFile(null) + .profileName("default") + .build(); + + IdentityResult result = resolver.resolveIdentity(Context.empty()); + assertNull(result.identity()); + assertTrue(result.error().contains("does not describe any credential source")); + } + + // --- Existing behaviors --------------------------------------------------------------------- + + @Test + void missingProfileReturnsErrorResult(@TempDir Path tmp) throws IOException { + Path creds = writeCredentials(tmp, """ + [default] + aws_access_key_id = K + aws_secret_access_key = S + """); + IdentityResult result = buildResolver(creds, "not-there") + .resolveIdentity(Context.empty()); + assertNull(result.identity()); + assertTrue(result.error().contains("not-there")); + } + + @Test + void refreshReloadsCredentialsFromDisk(@TempDir Path tmp) throws IOException { + Path creds = writeCredentials(tmp, """ + [default] + aws_access_key_id = V1 + aws_secret_access_key = S1 + """); + + AwsProfileCredentialsResolver resolver = buildResolver(creds, "default"); + assertEquals("V1", resolver.resolveIdentity(Context.empty()).unwrap().accessKeyId()); + + Files.writeString(creds, """ + [default] + aws_access_key_id = V2 + aws_secret_access_key = S2 + """, StandardCharsets.UTF_8); + + assertEquals("V1", resolver.resolveIdentity(Context.empty()).unwrap().accessKeyId()); + resolver.refresh(); + assertEquals("V2", resolver.resolveIdentity(Context.empty()).unwrap().accessKeyId()); + } + + @Test + void canUsePreloadedProfileFile(@TempDir Path tmp) throws IOException { + Path creds = writeCredentials(tmp, """ + [prod] + aws_access_key_id = PK + aws_secret_access_key = PS + """); + + AwsProfileFile file = AwsProfileFile.builder() + .configFile(null) + .credentialsFile(creds) + .build(); + + AwsProfileCredentialsResolver resolver = AwsProfileCredentialsResolver.builder() + .profileFile(file) + .profileName("prod") + .build(); + + assertEquals("PK", resolver.resolveIdentity(Context.empty()).unwrap().accessKeyId()); + } + + private static AwsProfileCredentialsResolver buildResolver(Path credentials, String profile) { + return AwsProfileCredentialsResolver.builder() + .configFile(null) + .credentialsFile(credentials) + .profileName(profile) + .build(); + } + + private static Path writeCredentials(Path tmp, String content) throws IOException { + Path p = tmp.resolve("credentials"); + Files.writeString(p, content, StandardCharsets.UTF_8); + return p; + } +} 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/CredentialProcessHandlerTest.java b/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/CredentialProcessHandlerTest.java new file mode 100644 index 000000000..e632687e5 --- /dev/null +++ b/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/CredentialProcessHandlerTest.java @@ -0,0 +1,150 @@ +/* + * 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 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.IdentityResult; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.config.AwsConfigCredentialSourceHandler.ResolutionContext; +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 = new CredentialProcessHandler().tryResolve(source, ctx()); + + 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 = new CredentialProcessHandler().tryResolve(source, ctx()).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 = new CredentialProcessHandler().tryResolve(source, ctx()).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 = new CredentialProcessHandler().tryResolve(source, ctx()); + + 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 = new CredentialProcessHandler().tryResolve(source, ctx()); + + assertNull(result.identity()); + assertTrue(result.error().contains("SecretAccessKey")); + } + + @Test + void returnsNullForNonCredentialProcessSource() { + AwsConfigCredentialSource.StaticKeys other = new AwsConfigCredentialSource.StaticKeys("AK", "SK", null); + assertNull(new CredentialProcessHandler().tryResolve(other, ctx())); + } + + @Test + void endToEndWithResolver(@TempDir Path tmp) throws IOException { + Path script = writeScript(tmp, """ + #!/bin/sh + echo '{"Version": 1, "AccessKeyId": "PROC_AK", "SecretAccessKey": "PROC_SK"}' + """); + + Path config = tmp.resolve("config"); + Files.writeString(config, """ + [profile proc] + credential_process = %s + """.formatted(script.toString()), StandardCharsets.UTF_8); + + AwsProfileCredentialsResolver resolver = AwsProfileCredentialsResolver.builder() + .configFile(config) + .credentialsFile(null) + .profileName("proc") + .addHandler(new CredentialProcessHandler()) + .addHandler(new StaticKeysHandler()) + .build(); + + AwsCredentialsIdentity id = resolver.resolveIdentity(Context.empty()).unwrap(); + assertEquals("PROC_AK", id.accessKeyId()); + assertEquals("PROC_SK", id.secretAccessKey()); + } + + 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 static ResolutionContext ctx() { + return new ResolutionContext(null, "test", Context.empty()); + } +} 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/build.gradle.kts b/aws/aws-credential-chain/build.gradle.kts new file mode 100644 index 000000000..586f46dff --- /dev/null +++ b/aws/aws-credential-chain/build.gradle.kts @@ -0,0 +1,14 @@ +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")) + implementation(project(":logging")) +} diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChain.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChain.java new file mode 100644 index 000000000..1faaecbf7 --- /dev/null +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChain.java @@ -0,0 +1,243 @@ +/* + * 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.EnumMap; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +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.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.auth.api.identity.AwsCredentialsResolver; +import software.amazon.smithy.java.context.Context; +import software.amazon.smithy.java.logging.InternalLogger; + +/** + * The AWS default credential provider chain. + * + *

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

Usage: + *

{@code
+ * AwsCredentialsResolver chain = AwsCredentialChain.create();
+ * IdentityResult 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 AwsCredentialChain implements AwsCredentialsResolver, AutoCloseable { + + private static final InternalLogger LOGGER = InternalLogger.getLogger(AwsCredentialChain.class); + + private final List resolvers; + private final ScheduledExecutorService executor; + + private AwsCredentialChain(List resolvers, ScheduledExecutorService executor) { + this.resolvers = resolvers; + this.executor = executor; + } + + /** + * Create a credential chain by discovering providers via ServiceLoader. + * + * @return the assembled chain. + * @throws IllegalStateException if two providers claim the same builtin slot. + */ + public static AwsCredentialChain create() { + List registrations = new ArrayList<>(); + for (AwsCredentialProvider r : ServiceLoader.load(AwsCredentialProvider.class)) { + registrations.add(r); + } + return assemble(registrations); + } + + static AwsCredentialChain assemble(List registrations) { + // Check for duplicate names. + Set seenNames = new HashSet<>(); + for (AwsCredentialProvider r : registrations) { + if (!seenNames.add(r.name())) { + throw new IllegalStateException("Duplicate credential provider registration name: '" + r.name() + "'"); + } + } + + // Separate builtins from relatives. + Map builtins = new EnumMap<>(BuiltinProvider.class); + List relatives = new ArrayList<>(); + + for (AwsCredentialProvider r : registrations) { + if (r.ordering() instanceof OrderingConstraint.Builtin(BuiltinProvider slot)) { + AwsCredentialProvider existing = builtins.put(slot, r); + if (existing != null) { + throw new IllegalStateException("Two credential providers claim the same slot '" + + slot + "': '" + existing.name() + "' and '" + r.name() + "'"); + } + } else { + relatives.add(r); + } + } + + // Use a single executor for each provider (used for caching). + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(r2 -> { + Thread t = new Thread(r2, "aws-credential-chain-refresh"); + t.setDaemon(true); + return t; + }); + ProviderContext ctx = new ProviderContext(executor, Context.create()); + + // Precompute insert positions: for each slot, how many claimed slots come before it + // and up to and including it. This avoids re-scanning the enum on every relative insert. + EnumMap insertAfter = new EnumMap<>(BuiltinProvider.class); + EnumMap insertBefore = new EnumMap<>(BuiltinProvider.class); + int count = 0; + for (BuiltinProvider slot : BuiltinProvider.values()) { + insertBefore.put(slot, count); + if (builtins.containsKey(slot)) { + count++; + } + insertAfter.put(slot, count); + } + + // Build the ordered list: builtin slots in enum order. + List ordered = new ArrayList<>(); + for (BuiltinProvider slot : BuiltinProvider.values()) { + AwsCredentialProvider r = builtins.get(slot); + if (r != null) { + ordered.add(new NamedResolver(r.name(), r.create(ctx))); + } + } + + // Insert relative providers using precomputed positions. + for (AwsCredentialProvider r : relatives) { + int insertAt; + if (r.ordering() instanceof OrderingConstraint.After(BuiltinProvider slot)) { + insertAt = insertAfter.get(slot); + } else if (r.ordering() instanceof OrderingConstraint.Before(BuiltinProvider slot)) { + insertAt = insertBefore.get(slot); + } else { + insertAt = ordered.size(); + } + if (insertAt > ordered.size()) { + insertAt = ordered.size(); + } + ordered.add(insertAt, new NamedResolver(r.name(), r.create(ctx))); + } + + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Assembled credential chain: {}", + ordered.stream().map(NamedResolver::name).collect(Collectors.joining(", "))); + } + + warnDetectedButUnclaimed(builtins); + return new AwsCredentialChain(Collections.unmodifiableList(ordered), executor); + } + + private static void warnDetectedButUnclaimed(Map builtins) { + for (BuiltinProvider slot : BuiltinProvider.values()) { + if (slot.moduleSuggestion() != null && !builtins.containsKey(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 + 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 (NamedResolver nr : resolvers) { + IdentityResult result = nr.resolver.resolveIdentity(requestProperties); + if (result.identity() != null) { + return 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 (BuiltinProvider slot : BuiltinProvider.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(BuiltinProvider slot) { + for (NamedResolver 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 (NamedResolver nr : resolvers) { + names.add(nr.name); + } + return names; + } + + @Override + public void invalidate() { + for (NamedResolver nr : resolvers) { + nr.resolver.invalidate(); + } + } + + @Override + public void close() { + executor.shutdownNow(); + } + + private record NamedResolver(String name, IdentityResolver resolver) {} +} diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialProvider.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialProvider.java new file mode 100644 index 000000000..b6017148d --- /dev/null +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialProvider.java @@ -0,0 +1,34 @@ +/* + * 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.auth.api.identity.IdentityResolver; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; + +/** + * SPI for registering a credential provider into the AWS default credential chain. + */ +public interface AwsCredentialProvider { + /** + * @return the unique name of this provider (for example {@code "environment"}, {@code "profile"}, {@code "imds"}). + */ + String name(); + + /** + * @return the ordering constraint for this provider. + */ + OrderingConstraint ordering(); + + /** + * Create the credential resolver for this provider. + * + *

Called once during chain assembly. The returned resolver is used for the lifetime of the chain. + * + * @param context shared resources provided by the chain. + * @return the resolver. + */ + IdentityResolver create(ProviderContext context); +} diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/BuiltinProvider.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/BuiltinProvider.java new file mode 100644 index 000000000..3cfbdb566 --- /dev/null +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/BuiltinProvider.java @@ -0,0 +1,106 @@ +/* + * 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; + +/** + * Builtin 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 BuiltinProvider { + /** Credentials explicitly provided in code. */ + CODE(null) { + @Override + public boolean isDetected() { + return false; + } + }, + + /** Credentials from JVM system properties ({@code aws.accessKeyId}, etc.). */ + 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}, etc.). */ + 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 ({@code AWS_WEB_IDENTITY_TOKEN_FILE} + {@code AWS_ROLE_ARN}). */ + 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; + } + }, + + /** Credentials from the AWS shared config/credentials files. */ + SHARED_CONFIG("software.amazon.smithy.java:aws-config") { + @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")); + } + }, + + /** Credentials from an HTTP endpoint (ECS container, EKS pod identity, etc.). */ + 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 EC2 instance metadata service (IMDS). */ + EC2_INSTANCE_METADATA("software.amazon.smithy.java:aws-credentials-imds") { + @Override + public boolean isDetected() { + // No cheap signal; IMDS requires a network call to detect. + return false; + } + }; + + private final String moduleSuggestion; + + BuiltinProvider(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/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..297d20e2d --- /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 an {@link AwsCredentialProvider} sits in the credential chain. + * + *

Three forms: + *

    + *
  • {@link Builtin} — claims a builtin 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 builtin slot.
  • + *
  • {@link After} — positions the provider immediately after a builtin slot.
  • + *
+ * + *

{@link Before} and {@link After} reference {@link BuiltinProvider} enum values only, not + * arbitrary provider names. This eliminates the possibility of cycles in ordering constraints. + */ +public sealed interface OrderingConstraint { + /** + * Claims a builtin slot in the default chain. Only one provider may claim each slot. + * + * @param slot the builtin slot to claim. + */ + record Builtin(BuiltinProvider slot) implements OrderingConstraint { + public Builtin { + if (slot == null) { + throw new IllegalArgumentException("slot must not be null"); + } + } + } + + /** + * Positions a provider immediately before the given builtin slot. + * + * @param slot the builtin slot this provider must come before. + */ + record Before(BuiltinProvider slot) implements OrderingConstraint { + public Before { + if (slot == null) { + throw new IllegalArgumentException("slot must not be null"); + } + } + } + + /** + * Positions a provider immediately after the given builtin slot. + * + * @param slot the builtin slot this provider must come after. + */ + record After(BuiltinProvider 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/ProviderContext.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ProviderContext.java new file mode 100644 index 000000000..9957e8db1 --- /dev/null +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ProviderContext.java @@ -0,0 +1,24 @@ +/* + * 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.concurrent.ScheduledExecutorService; +import software.amazon.smithy.java.context.Context; + +/** + * Context passed to {@link AwsCredentialProvider#create(ProviderContext)} during chain assembly. + * + *

Carries shared resources that providers may use. Currently provides: + *

    + *
  • A shared {@link ScheduledExecutorService} for background credential refresh tasks.
  • + *
  • A {@link Context} property bag for sharing data between providers (e.g., a parsed + * config file).
  • + *
+ * + * @param executor shared executor for background refresh. + * @param properties shared property bag for cross-provider data. + */ +public record ProviderContext(ScheduledExecutorService executor, Context properties) {} 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..3a8ab7920 --- /dev/null +++ b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java @@ -0,0 +1,176 @@ +/* + * 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.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.context.Context; + +class AwsCredentialChainTest { + @Test + void builtinProvidersAreOrderedByEnumOrder() { + var chain = AwsCredentialChain.assemble(List.of( + registration("imds", + new OrderingConstraint.Builtin(BuiltinProvider.EC2_INSTANCE_METADATA), + errorResolver("imds")), + registration("env", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("env")), + registration("profile", + new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), + errorResolver("profile")))); + + assertEquals(List.of("env", "profile", "imds"), chain.providerNames()); + } + + @Test + void firstSuccessfulProviderWins() { + var chain = AwsCredentialChain.assemble(List.of( + registration("env", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("env")), + registration("profile", + new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), + staticResolver("AK", "SK")))); + IdentityResult result = chain.resolveIdentity(Context.empty()); + + assertNotNull(result.identity()); + assertEquals("AK", result.identity().accessKeyId()); + } + + @Test + void allFailReturnsAggregatedError() { + var chain = AwsCredentialChain.assemble(List.of( + registration("env", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("no env")), + registration("profile", + new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), + errorResolver("no profile")))); + 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, + () -> AwsCredentialChain.assemble(List.of( + registration("a", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("a")), + registration("b", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("b"))))); + } + + @Test + void relativeAfterInsertsCorrectly() { + var chain = AwsCredentialChain.assemble(List.of( + registration("env", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("env")), + registration("profile", + new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), + errorResolver("profile")), + registration("custom", + new OrderingConstraint.After(BuiltinProvider.ENVIRONMENT), + errorResolver("custom")))); + + assertEquals(List.of("env", "custom", "profile"), chain.providerNames()); + } + + @Test + void relativeBeforeInsertsCorrectly() { + var chain = AwsCredentialChain.assemble(List.of( + registration("env", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("env")), + registration("profile", + new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), + errorResolver("profile")), + registration("custom", + new OrderingConstraint.Before(BuiltinProvider.SHARED_CONFIG), + errorResolver("custom")))); + + assertEquals(List.of("env", "custom", "profile"), chain.providerNames()); + } + + @Test + void relativeToUnclaimedSlotAppendsAtEnd() { + var chain = AwsCredentialChain.assemble(List.of( + registration("env", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("env")), + registration("custom", + new OrderingConstraint.After(BuiltinProvider.EC2_INSTANCE_METADATA), + errorResolver("custom")))); + assertEquals(List.of("env", "custom"), chain.providerNames()); + } + + @Test + void duplicateNameThrows() { + assertThrows(IllegalStateException.class, + () -> AwsCredentialChain.assemble(List.of( + registration("env", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("env")), + registration("env", + new OrderingConstraint.Builtin(BuiltinProvider.JAVA_SYSTEM_PROPERTIES), + errorResolver("env2"))))); + } + + @Test + void emptyChainReturnsDescriptiveError() { + var chain = AwsCredentialChain.assemble(List.of()); + IdentityResult result = chain.resolveIdentity(Context.empty()); + + assertNull(result.identity()); + assertTrue(result.error().contains("No credential providers were discovered")); + } + + private static AwsCredentialProvider registration( + String name, + OrderingConstraint ordering, + AwsCredentialsResolver resolver + ) { + return new AwsCredentialProvider() { + @Override + public String name() { + return name; + } + + @Override + public OrderingConstraint ordering() { + return ordering; + } + + @Override + public AwsCredentialsResolver create(ProviderContext context) { + return resolver; + } + }; + } + + private static AwsCredentialsResolver errorResolver(String msg) { + return ctx -> IdentityResult.ofError(AwsCredentialChainTest.class, msg); + } + + private static AwsCredentialsResolver staticResolver(String ak, String sk) { + return ctx -> IdentityResult.of(AwsCredentialsIdentity.create(ak, sk)); + } +} diff --git a/aws/aws-credentials-imds/build.gradle.kts b/aws/aws-credentials-imds/build.gradle.kts new file mode 100644 index 000000000..554978572 --- /dev/null +++ b/aws/aws-credentials-imds/build.gradle.kts @@ -0,0 +1,19 @@ +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(":aws:aws-config")) + 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..d6b52de98 --- /dev/null +++ b/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java @@ -0,0 +1,190 @@ +/* + * 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 software.amazon.smithy.java.auth.api.identity.CachingIdentityResolver; +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.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.AwsCredentialProvider; +import software.amazon.smithy.java.aws.credentials.chain.BuiltinProvider; +import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; +import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; +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 BuiltinProvider#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 AwsCredentialProvider { + + 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.Builtin(BuiltinProvider.EC2_INSTANCE_METADATA); + } + + @Override + public IdentityResolver create(ProviderContext context) { + AwsProfileFile profileFile = context.properties().get(AwsProfileFile.CONTEXT_KEY); + if (isDisabled(profileFile)) { + return new DisabledResolver(); + } + + URI endpoint = resolveEndpoint(); + String profileName = resolveProfileName(profileFile); + ImdsClient client = new ImdsClient(endpoint); + AwsCredentialsResolver delegate = ctx -> fetchAndParse(client, profileName); + + return CachingIdentityResolver.builder(delegate) + .executor(context.executor()) + .allowExpiredCredentials(true) // Static stability + .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.AwsCredentialProvider b/aws/aws-credentials-imds/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider 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.AwsCredentialProvider @@ -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/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..128dd76e7 --- /dev/null +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/AwsCredentialChainPlugin.java @@ -0,0 +1,62 @@ +/* + * 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.AwsCredentialChain; +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)) { + config.addIdentityResolver(AwsCredentialChain.create()); + } + } + + 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/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..6e7608e8e --- /dev/null +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java @@ -0,0 +1,34 @@ +/* + * 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 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.AwsCredentialProvider; +import software.amazon.smithy.java.aws.credentials.chain.BuiltinProvider; +import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; +import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; + +/** + * Registers {@link EnvironmentVariableIdentityResolver} in the credential chain's + * {@link BuiltinProvider#ENVIRONMENT} slot. + */ +public final class EnvironmentCredentialProvider implements AwsCredentialProvider { + @Override + public String name() { + return "Environment"; + } + + @Override + public OrderingConstraint ordering() { + return new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT); + } + + @Override + public IdentityResolver create(ProviderContext context) { + return 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..0541c0fc7 --- /dev/null +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java @@ -0,0 +1,34 @@ +/* + * 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 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.AwsCredentialProvider; +import software.amazon.smithy.java.aws.credentials.chain.BuiltinProvider; +import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; +import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; + +/** + * Registers {@link SystemPropertiesIdentityResolver} in the credential chain's + * {@link BuiltinProvider#JAVA_SYSTEM_PROPERTIES} slot. + */ +public final class SystemPropertiesCredentialProvider implements AwsCredentialProvider { + @Override + public String name() { + return "JavaSystemProperties"; + } + + @Override + public OrderingConstraint ordering() { + return new OrderingConstraint.Builtin(BuiltinProvider.JAVA_SYSTEM_PROPERTIES); + } + + @Override + public IdentityResolver create(ProviderContext context) { + return 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.AwsCredentialProvider b/aws/client/aws-client-core/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider 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.AwsCredentialProvider @@ -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/settings.gradle.kts b/settings.gradle.kts index 5c86e4f7a..9f86b4b53 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -86,6 +86,9 @@ 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-config") +include(":aws:aws-credential-chain") +include(":aws:aws-credentials-imds") // AWS service bundling code include(":aws:aws-service-bundle")