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:
+ *
+ * - On first call (cold start), blocks until the delegate returns a result.
+ * - On subsequent calls, returns the cached identity immediately. A background task refreshes the identity when
+ * it enters the prefetch window ({@code expiration - prefetchBuffer}).
+ * - If the background refresh fails and {@link Builder#allowExpiredCredentials(boolean)} is {@code true}
+ * (static stability), the expired cached value continues to be returned and refresh is retried after a
+ * jittered 5-10 minute delay.
+ * - If the background refresh fails and {@code allowExpiredCredentials} is {@code false} (default), the next
+ * caller that finds the cache expired will block for one synchronous retry.
+ * - If the delegate returns an identity with no expiration, it is cached indefinitely until
+ * {@link #invalidate()} is called.
+ *
+ *
+ * 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:
+ *
+ * - The {@code HOME} environment variable, on any platform.
+ * - On Windows platforms only: the {@code USERPROFILE} environment variable.
+ * - On Windows platforms only: the concatenation of {@code HOMEDRIVE} and {@code HOMEPATH}.
+ * - The {@code user.home} system property (the language-specific fallback permitted by the SEP).
+ *
+ *
+ * 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:
+ *
+ * - {@link AwsConfigCredentialSource.WebIdentityToken} when {@code role_arn} and
+ * {@code web_identity_token_file} are both set.
+ * - {@link AwsConfigCredentialSource.AssumeRole} when {@code role_arn} is set and no
+ * {@code web_identity_token_file} is present.
+ * - {@link AwsConfigCredentialSource.SsoSession} when {@code sso_session},
+ * {@code sso_account_id}, and {@code sso_role_name} are all set.
+ * - {@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.
+ * - {@link AwsConfigCredentialSource.LoginSession} when {@code login_session} is set.
+ * - {@link AwsConfigCredentialSource.CredentialProcess} when {@code credential_process} is set.
+ * - {@link AwsConfigCredentialSource.SessionKeys} when {@code aws_access_key_id},
+ * {@code aws_secret_access_key}, and {@code aws_session_token} are all set.
+ * - {@link AwsConfigCredentialSource.StaticKeys} when {@code aws_access_key_id} and
+ * {@code aws_secret_access_key} are set (and no session token).
+ *
+ *
+ * @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
+ *
+ *
+ * - Builder's {@code profileName}, if set.
+ * - The {@code AWS_PROFILE} environment variable, if set and non-empty.
+ * - The {@code AWS_DEFAULT_PROFILE} environment variable, if set and non-empty.
+ * - The literal {@code "default"}.
+ *
+ *
+ * {@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