From 7190d013d4375f4279d3b0c922a0e8197b4fadfe Mon Sep 17 00:00:00 2001 From: Michael Dowling Date: Thu, 7 May 2026 17:40:17 -0500 Subject: [PATCH 01/13] Add modular AWS credential resolution Adds support for loading AWS credentials from shared config/credentials files and assembling them into a pluggable credential provider chain. New modules: - aws-config: SEP-conformant INI parser, profile data model, AwsConfigCredentialSource sealed types, handler SPI with ServiceLoader discovery, and built-in handlers for static keys, session keys, and credential_process. - aws-credential-chain: Credential provider chain with builtin slots, Before/After relative ordering, SPI-based provider discovery, cheap environment detection, and actionable error messages when implementation modules are missing. Changes to existing modules: - auth-api: Add CachingIdentityResolver with async background refresh, static stability support, injectable Clock and ScheduledExecutorService. Add invalidate() default method to IdentityResolver interface. Will be used by STS, SSO, etc. - aws-client-core: Add AwsCredentialChainPlugin ClientPlugin, register EnvironmentCredentialProvider and SystemPropertiesCredentialProvider as chain sources via SPI. Both now read AWS_ACCOUNT_ID / aws.accountId per the account ID SEP. - settings.gradle.kts: Include new modules. Architecture overview: - Data model (aws-config) is separated from resolution policy (chain). - Credential sources are detected cheaply from profile properties without needing implementation modules (STS, SSO, IMDS). - Handlers are discovered via ServiceLoader; missing handlers produce errors naming the dependency to add. - Chain ordering uses a fixed enum for builtins and simple Before/After insertion for third-party providers. - CachingIdentityResolver provides background refresh with a shared ScheduledExecutorService passed via ProviderContext. - invalidate() propagates through the chain to force credential refresh on auth failures. TODO items: add SSO, STS, IMDS, ECS, etc. --- .../api/identity/CachingIdentityResolver.java | 361 ++++ .../auth/api/identity/IdentityResolver.java | 12 + .../identity/CachingIdentityResolverTest.java | 267 +++ aws/aws-config/build.gradle.kts | 19 + .../config/AwsProfileFileParserFuzzTest.java | 25 + .../aws/config/AwsConfigCredentialSource.java | 203 +++ .../AwsConfigCredentialSourceHandler.java | 54 + .../java/aws/config/AwsConfigFileType.java | 17 + .../java/aws/config/AwsHomeResolver.java | 117 ++ .../smithy/java/aws/config/AwsProfile.java | 212 +++ .../config/AwsProfileCredentialsResolver.java | 329 ++++ .../java/aws/config/AwsProfileFile.java | 359 ++++ .../java/aws/config/AwsProfileFileParser.java | 239 +++ .../aws/config/ConfigFileParseException.java | 33 + .../aws/config/CredentialProcessHandler.java | 150 ++ .../aws/config/ProfileCredentialProvider.java | 39 + .../java/aws/config/ProfileStandardizer.java | 255 +++ .../java/aws/config/SessionKeysHandler.java | 32 + .../java/aws/config/StaticKeysHandler.java | 32 + ...ws.config.AwsConfigCredentialSourceHandler | 3 + ...ws.credentials.chain.AwsCredentialProvider | 1 + .../java/aws/config/AwsHomeResolverTest.java | 117 ++ .../AwsProfileCredentialSourcesTest.java | 202 +++ .../AwsProfileCredentialsResolverTest.java | 261 +++ .../aws/config/AwsProfileFileParserTest.java | 246 +++ .../java/aws/config/AwsProfileFileTest.java | 248 +++ .../config/CredentialProcessHandlerTest.java | 150 ++ .../aws/config/ProfileStandardizerTest.java | 187 ++ .../config/SepLocationConformanceTest.java | 104 ++ .../aws/config/SepParserConformanceTest.java | 234 +++ .../config/config-file-location-tests.json | 135 ++ .../aws/config/config-file-parser-tests.json | 1572 +++++++++++++++++ aws/aws-credential-chain/build.gradle.kts | 14 + .../credentials/chain/AwsCredentialChain.java | 246 +++ .../chain/AwsCredentialProvider.java | 41 + .../credentials/chain/BuiltinProvider.java | 106 ++ .../credentials/chain/OrderingConstraint.java | 50 + .../credentials/chain/ProviderContext.java | 17 + .../chain/AwsCredentialChainTest.java | 176 ++ aws/client/aws-client-core/build.gradle.kts | 1 + .../client/core/AwsCredentialChainPlugin.java | 62 + .../EnvironmentCredentialProvider.java | 33 + .../EnvironmentVariableIdentityResolver.java | 41 +- .../SystemPropertiesCredentialProvider.java | 33 + .../SystemPropertiesIdentityResolver.java | 42 +- ...ws.credentials.chain.AwsCredentialProvider | 2 + settings.gradle.kts | 2 + 47 files changed, 7064 insertions(+), 17 deletions(-) create mode 100644 auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/CachingIdentityResolver.java create mode 100644 auth-api/src/test/java/software/amazon/smithy/java/auth/api/identity/CachingIdentityResolverTest.java create mode 100644 aws/aws-config/build.gradle.kts create mode 100644 aws/aws-config/src/fuzz/java/software/amazon/smithy/java/aws/config/AwsProfileFileParserFuzzTest.java create mode 100644 aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsConfigCredentialSource.java create mode 100644 aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsConfigCredentialSourceHandler.java create mode 100644 aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsConfigFileType.java create mode 100644 aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsHomeResolver.java create mode 100644 aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsProfile.java create mode 100644 aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsProfileCredentialsResolver.java create mode 100644 aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsProfileFile.java create mode 100644 aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsProfileFileParser.java create mode 100644 aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/ConfigFileParseException.java create mode 100644 aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/CredentialProcessHandler.java create mode 100644 aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/ProfileCredentialProvider.java create mode 100644 aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/ProfileStandardizer.java create mode 100644 aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/SessionKeysHandler.java create mode 100644 aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/StaticKeysHandler.java create mode 100644 aws/aws-config/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.config.AwsConfigCredentialSourceHandler create mode 100644 aws/aws-config/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider create mode 100644 aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/AwsHomeResolverTest.java create mode 100644 aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/AwsProfileCredentialSourcesTest.java create mode 100644 aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/AwsProfileCredentialsResolverTest.java create mode 100644 aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/AwsProfileFileParserTest.java create mode 100644 aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/AwsProfileFileTest.java create mode 100644 aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/CredentialProcessHandlerTest.java create mode 100644 aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/ProfileStandardizerTest.java create mode 100644 aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/SepLocationConformanceTest.java create mode 100644 aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/SepParserConformanceTest.java create mode 100644 aws/aws-config/src/test/resources/software/amazon/smithy/java/aws/config/config-file-location-tests.json create mode 100644 aws/aws-config/src/test/resources/software/amazon/smithy/java/aws/config/config-file-parser-tests.json create mode 100644 aws/aws-credential-chain/build.gradle.kts create mode 100644 aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChain.java create mode 100644 aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialProvider.java create mode 100644 aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/BuiltinProvider.java create mode 100644 aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/OrderingConstraint.java create mode 100644 aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ProviderContext.java create mode 100644 aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java create mode 100644 aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/AwsCredentialChainPlugin.java create mode 100644 aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java create mode 100644 aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java create mode 100644 aws/client/aws-client-core/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider diff --git a/auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/CachingIdentityResolver.java b/auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/CachingIdentityResolver.java new file mode 100644 index 000000000..f16416305 --- /dev/null +++ b/auth-api/src/main/java/software/amazon/smithy/java/auth/api/identity/CachingIdentityResolver.java @@ -0,0 +1,361 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.auth.api.identity; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import software.amazon.smithy.java.context.Context; + +/** * An {@link IdentityResolver} that caches the result of a delegate resolver and refreshes it asynchronously in the + * background before expiration. + * + *

Behavior: + *

+ * + *

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

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

The default implementation is a no-op. Caching resolvers (such as {@link CachingIdentityResolver}) override + * this to clear their cache. + */ + default void invalidate() {} + /** * Combines multiple identity resolvers with the same identity type into a single resolver. * diff --git a/auth-api/src/test/java/software/amazon/smithy/java/auth/api/identity/CachingIdentityResolverTest.java b/auth-api/src/test/java/software/amazon/smithy/java/auth/api/identity/CachingIdentityResolverTest.java new file mode 100644 index 000000000..a36516f3a --- /dev/null +++ b/auth-api/src/test/java/software/amazon/smithy/java/auth/api/identity/CachingIdentityResolverTest.java @@ -0,0 +1,267 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.auth.api.identity; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.java.context.Context; + +class CachingIdentityResolverTest { + + private final ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(); + private CachingIdentityResolver resolver; + + @AfterEach + void tearDown() { + if (resolver != null) { + resolver.close(); + } + executor.shutdownNow(); + } + + @Test + void coldStartBlocksAndCachesResult() { + var identity = new TestIdentity("cached", Instant.now().plusSeconds(3600)); + var delegate = new CountingResolver(identity); + resolver = CachingIdentityResolver.builder(delegate) + .executor(executor) + .build(); + + IdentityResult result = resolve(); + assertNotNull(result.identity()); + assertEquals("cached", result.identity().value); + assertEquals(1, delegate.callCount.get()); + + // Second call returns cached — no delegate invocation. + IdentityResult result2 = resolve(); + assertEquals("cached", result2.identity().value); + assertEquals(1, delegate.callCount.get()); + } + + @Test + void backgroundRefreshHappensBeforeExpiry() throws InterruptedException { + Instant now = Instant.now(); + // Credentials expire in 2 seconds, prefetch buffer is 1 second → refresh at T+1s. + var delegate = new CountingResolver(expiringIdentity(now.plusSeconds(2))); + resolver = CachingIdentityResolver.builder(delegate) + .executor(executor) + .prefetchBuffer(Duration.ofSeconds(1)) + .build(); + + // Cold start. + resolve(); + assertEquals(1, delegate.callCount.get()); + + // Wait for background refresh to fire (should happen ~1s from now). + Thread.sleep(1500); + assertTrue(delegate.callCount.get() >= 2, "Expected background refresh, got " + delegate.callCount.get()); + } + + @Test + void nonExpiringIdentityIsCachedIndefinitely() throws InterruptedException { + var delegate = new CountingResolver(new TestIdentity("permanent", null)); + resolver = CachingIdentityResolver.builder(delegate) + .executor(executor) + .build(); + + resolve(); + Thread.sleep(200); + resolve(); + assertEquals(1, delegate.callCount.get()); + } + + @Test + void allowExpiredCredentialsReturnsStaleOnFailure() { + AtomicInteger calls = new AtomicInteger(0); + IdentityResolver delegate = new IdentityResolver<>() { + @Override + public IdentityResult resolveIdentity(Context ctx) { + if (calls.incrementAndGet() == 1) { + return IdentityResult.of(expiringIdentity(Instant.now().minusSeconds(10))); + } + throw new RuntimeException("refresh failed"); + } + + @Override + public Class identityType() { + return TestIdentity.class; + } + }; + + // Use a fixed clock that's past expiration so the cache is immediately stale. + Clock pastClock = Clock.fixed(Instant.now().plusSeconds(60), ZoneId.of("UTC")); + resolver = CachingIdentityResolver.builder(delegate) + .executor(executor) + .allowExpiredCredentials(true) + .clock(pastClock) + .prefetchBuffer(Duration.ofSeconds(1)) + .build(); + + // Cold start succeeds (returns expired identity). + IdentityResult result = resolve(); + assertNotNull(result.identity()); + + // Trigger refresh — it fails, but we still get the stale value. + IdentityResult result2 = resolve(); + assertNotNull(result2.identity()); + } + + @Test + void strictModeReturnsErrorWhenExpiredAndRefreshFails() { + Instant expiration = Instant.now().plusMillis(50); // Expires very soon. + var identity = new TestIdentity("expiring", expiration); + AtomicInteger calls = new AtomicInteger(0); + IdentityResolver delegate = new IdentityResolver<>() { + @Override + public IdentityResult resolveIdentity(Context ctx) { + if (calls.incrementAndGet() == 1) { + return IdentityResult.of(identity); + } + return IdentityResult.ofError(getClass(), "no creds"); + } + + @Override + public Class identityType() { + return TestIdentity.class; + } + }; + + resolver = CachingIdentityResolver.builder(delegate) + .executor(executor) + .allowExpiredCredentials(false) + .prefetchBuffer(Duration.ofMillis(10)) + .build(); + + // Cold start succeeds. + IdentityResult first = resolve(); + assertNotNull(first.identity()); + + // Wait for expiration. + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + // Now expired + strict → blocks for retry → delegate returns error. + IdentityResult result = resolve(); + assertNull(result.identity()); + assertNotNull(result.error()); + } + + @Test + void invalidateForcesNextCallToRefresh() { + var delegate = new CountingResolver(expiringIdentity(Instant.now().plusSeconds(3600))); + resolver = CachingIdentityResolver.builder(delegate) + .executor(executor) + .build(); + + resolve(); + assertEquals(1, delegate.callCount.get()); + + resolver.invalidate(); + resolve(); + assertEquals(2, delegate.callCount.get()); + } + + @Test + void concurrentColdStartOnlyCallsDelegateOnce() throws InterruptedException { + CountDownLatch startGate = new CountDownLatch(1); + var delegate = new IdentityResolver() { + final AtomicInteger callCount = new AtomicInteger(0); + + @Override + public IdentityResult resolveIdentity(Context ctx) { + callCount.incrementAndGet(); + try { + Thread.sleep(100); // Simulate slow first fetch. + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return IdentityResult.of(new TestIdentity("shared", Instant.now().plusSeconds(3600))); + } + + @Override + public Class identityType() { + return TestIdentity.class; + } + }; + + resolver = CachingIdentityResolver.builder(delegate) + .executor(executor) + .build(); + + int threadCount = 10; + CountDownLatch done = new CountDownLatch(threadCount); + AtomicReference firstValue = new AtomicReference<>(); + + for (int i = 0; i < threadCount; i++) { + new Thread(() -> { + try { + startGate.await(); + } catch (InterruptedException e) { + return; + } + @SuppressWarnings("unchecked") + var r = (CachingIdentityResolver) resolver; + IdentityResult result = r.resolveIdentity(Context.empty()); + firstValue.compareAndSet(null, result.identity().value); + done.countDown(); + }).start(); + } + + startGate.countDown(); + assertTrue(done.await(5, TimeUnit.SECONDS)); + assertEquals(1, delegate.callCount.get()); + assertEquals("shared", firstValue.get()); + } + + @SuppressWarnings("unchecked") + private IdentityResult resolve() { + return ((CachingIdentityResolver) resolver).resolveIdentity(Context.empty()); + } + + private static TestIdentity expiringIdentity(Instant expiration) { + return new TestIdentity("id-" + System.nanoTime(), expiration); + } + + record TestIdentity(String value, Instant expirationTime) implements Identity {} + + static class CountingResolver implements IdentityResolver { + final AtomicInteger callCount = new AtomicInteger(0); + private final TestIdentity identity; + + CountingResolver(TestIdentity identity) { + this.identity = identity; + } + + @Override + public IdentityResult resolveIdentity(Context ctx) { + callCount.incrementAndGet(); + return IdentityResult.of(identity); + } + + @Override + public Class identityType() { + return TestIdentity.class; + } + } +} diff --git a/aws/aws-config/build.gradle.kts b/aws/aws-config/build.gradle.kts new file mode 100644 index 000000000..ea9d061c9 --- /dev/null +++ b/aws/aws-config/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id("smithy-java.module-conventions") + id("smithy-java.fuzz-test") +} + +description = "This module provides parsing of AWS shared config and credentials files " + + "(~/.aws/config, ~/.aws/credentials) and an AwsCredentialsResolver backed by them." + +extra["displayName"] = "Smithy :: Java :: AWS :: Config" +extra["moduleName"] = "software.amazon.smithy.java.aws.config" + +dependencies { + api(project(":aws:aws-auth-api")) + api(project(":auth-api")) + implementation(project(":logging")) + implementation(project(":codecs:json-codec", configuration = "shadow")) + implementation(project(":aws:aws-credential-chain")) + testImplementation("tools.jackson.core:jackson-databind:3.1.2") +} diff --git a/aws/aws-config/src/fuzz/java/software/amazon/smithy/java/aws/config/AwsProfileFileParserFuzzTest.java b/aws/aws-config/src/fuzz/java/software/amazon/smithy/java/aws/config/AwsProfileFileParserFuzzTest.java new file mode 100644 index 000000000..bcbd23eea --- /dev/null +++ b/aws/aws-config/src/fuzz/java/software/amazon/smithy/java/aws/config/AwsProfileFileParserFuzzTest.java @@ -0,0 +1,25 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.config; + +import com.code_intelligence.jazzer.junit.FuzzTest; +import java.nio.charset.StandardCharsets; + +/** + * Fuzz test for the AWS config file parser. Ensures no input can cause unexpected exceptions, + * OOM, or infinite loops. The only acceptable exception is {@link ConfigFileParseException}. + */ +class AwsProfileFileParserFuzzTest { + + @FuzzTest(maxDuration = "5m") + void fuzzParser(byte[] data) { + try { + AwsProfileFileParser.parse(new String(data, StandardCharsets.UTF_8)); + } catch (ConfigFileParseException expected) { + // Valid failure mode — malformed input. + } + } +} diff --git a/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsConfigCredentialSource.java b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsConfigCredentialSource.java new file mode 100644 index 000000000..50ae07eaf --- /dev/null +++ b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsConfigCredentialSource.java @@ -0,0 +1,203 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.config; + +import java.util.Map; +import java.util.Objects; + +/** + * AWS Config file credential types. + * + *

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

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

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

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

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

Handlers that walk {@code source_profile} chains can look the referenced profile up via + * {@link #profileFile()} and invoke a child resolution. Cycle detection is the caller's responsibility + * (the resolver maintains a visited-set while recursing). + * + * @param profileFile Entire merged config file data. + * @param profileName Profile name to use. + * @param requestProperties Context properties associated with the request. + */ + record ResolutionContext(AwsProfileFile profileFile, String profileName, Context requestProperties) {} +} diff --git a/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsConfigFileType.java b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsConfigFileType.java new file mode 100644 index 000000000..f629eb36f --- /dev/null +++ b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsConfigFileType.java @@ -0,0 +1,17 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.config; + +/** + * Identifies which of the two AWS shared configuration files is being parsed. + */ +public enum AwsConfigFileType { + /** The configuration file (e.g., {@code ~/.aws/config}). */ + CONFIGURATION, + + /** The shared credentials file (e.g., {@code ~/.aws/credentials}). */ + CREDENTIALS +} diff --git a/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsHomeResolver.java b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsHomeResolver.java new file mode 100644 index 000000000..036023254 --- /dev/null +++ b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsHomeResolver.java @@ -0,0 +1,117 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.config; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Locale; +import java.util.function.Function; + +/** + * Resolves the user's home directory using the same chain of environment variables described in + * the AWS SDK shared-configuration specification, and supports the tilde-expansion syntax used in + * the {@code AWS_CONFIG_FILE} / {@code AWS_SHARED_CREDENTIALS_FILE} environment variables. + * + *

Resolution order: + *

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

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

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

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

A profile has: + *

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

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

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

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

Architecture

+ * + *

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

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

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

Profile name selection

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

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

If no handlers are registered before {@link #build()}, the resolver discovers + * handlers via {@link java.util.ServiceLoader}. Calling this method replaces ServiceLoader discovery + * entirely; only explicitly added handlers will be used. + */ + public Builder addHandler(AwsConfigCredentialSourceHandler handler) { + this.handlers.add(Objects.requireNonNull(handler, "handler")); + return this; + } + + /** + * When {@code true}, credential sources that no handler claims are skipped and the next source in priority + * order is attempted. When {@code false} (the default), an unhandled source causes an immediate error, + * matching the AWS SDK shared-configuration specification's requirement that the highest-priority source + * MUST be used. + */ + public Builder ignoreUnhandledSources(boolean ignoreUnhandledSources) { + this.ignoreUnhandledSources = ignoreUnhandledSources; + return this; + } + + /** + * Build the resolver. + */ + public AwsProfileCredentialsResolver build() { + return new AwsProfileCredentialsResolver(this); + } + } +} diff --git a/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsProfileFile.java b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsProfileFile.java new file mode 100644 index 000000000..2a83719fc --- /dev/null +++ b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsProfileFile.java @@ -0,0 +1,359 @@ +/* + * 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.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"; + + 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..dbab3d81f --- /dev/null +++ b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/ProfileCredentialProvider.java @@ -0,0 +1,39 @@ +/* + * 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.List; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsResolver; +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 List aliases() { + return List.of("SharedCredentials"); + } + + @Override + public OrderingConstraint ordering() { + return new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG); + } + + @Override + public AwsCredentialsResolver create(ProviderContext context) { + return AwsProfileCredentialsResolver.builder().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..a5ed904c0 --- /dev/null +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChain.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.credentials.chain; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.ServiceLoader; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +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) { + // Index by name and aliases. + Map byName = new HashMap<>(); + for (AwsCredentialProvider r : registrations) { + if (byName.put(r.name(), r) != null) { + throw new IllegalStateException("Duplicate credential provider registration name: '" + r.name() + "'"); + } + for (String alias : r.aliases()) { + byName.put(alias, r); + } + } + + // Separate builtins from relatives. + Map builtins = new LinkedHashMap<>(); + List relatives = new ArrayList<>(); + + for (AwsCredentialProvider r : registrations) { + if (r.ordering() instanceof OrderingConstraint.Builtin(BuiltinProvider slot)) { + AwsCredentialProvider existing = builtins.put(slot, r); + if (existing != null) { + throw new IllegalStateException("Two credential providers claim the same slot '" + + slot + "': '" + existing.name() + "' and '" + r.name() + "'"); + } + } else { + relatives.add(r); + } + } + + // Use a single executor for each provider (used for caching). + ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(r2 -> { + Thread t = new Thread(r2, "aws-credential-chain-refresh"); + t.setDaemon(true); + return t; + }); + ProviderContext ctx = new ProviderContext(executor); + + // Build the ordered list: builtin slots first (in enum order), then insert relatives. + List ordered = new ArrayList<>(); + List orderedNames = new ArrayList<>(); + + for (BuiltinProvider slot : BuiltinProvider.values()) { + AwsCredentialProvider r = builtins.get(slot); + if (r != null) { + ordered.add(new NamedResolver(r.name(), r.create(ctx))); + orderedNames.add(r.name()); + } + } + + // Insert relative providers. + for (AwsCredentialProvider r : relatives) { + String target; + int insertAt; + if (r.ordering() instanceof OrderingConstraint.After(String provider)) { + target = resolveName(provider, byName); + int idx = orderedNames.indexOf(target); + if (idx < 0) { + throw new IllegalStateException("Credential provider '" + r.name() + "' references unknown " + + "provider '" + provider + "' in its 'after' constraint."); + } + insertAt = idx + 1; + } else if (r.ordering() instanceof OrderingConstraint.Before(String provider)) { + target = resolveName(provider, byName); + int idx = orderedNames.indexOf(target); + if (idx < 0) { + throw new IllegalStateException("Credential provider '" + r.name() + "' references unknown " + + "provider '" + provider + "' in its 'before' constraint."); + } + insertAt = idx; + } else { + insertAt = ordered.size(); + } + + ordered.add(insertAt, new NamedResolver(r.name(), r.create(ctx))); + orderedNames.add(insertAt, r.name()); + } + + LOGGER.debug("Assembled credential chain: {}", orderedNames); + warnDetectedButUnclaimed(builtins); + return new AwsCredentialChain(Collections.unmodifiableList(ordered), executor); + } + + private static void warnDetectedButUnclaimed(Map builtins) { + for (BuiltinProvider slot : BuiltinProvider.values()) { + if (!builtins.containsKey(slot) && slot.moduleSuggestion() != null && slot.isDetected()) { + LOGGER.warn("{} credentials detected but no provider is registered for the '{}' slot. " + + "Add '{}' to your dependencies.", + slot.name(), + slot.name(), + slot.moduleSuggestion()); + } + } + } + + @Override + public IdentityResult resolveIdentity(Context requestProperties) { + if (resolvers.isEmpty()) { + return IdentityResult.ofError(getClass(), + "No credential providers were discovered. Ensure at least one " + + "aws-credentials-* module is on the classpath." + detectedButMissingHints()); + } + + // More cheaply build up a list of failures, and defer string-ing them into a StringBuilder. + List errors = new ArrayList<>(); + for (NamedResolver nr : resolvers) { + IdentityResult result = nr.resolver.resolveIdentity(requestProperties); + if (result.identity() != null) { + return result; + } + errors.add(nr.name); + errors.add(result.error()); + } + + StringBuilder missing = new StringBuilder(); + for (var i = 0; i < errors.size(); i += 2) { + if (i > 0) { + errors.add("; "); + } + missing.append(errors.get(i)).append(": ").append(errors.get(i + 1)); + } + + return IdentityResult.ofError(getClass(), + "Unable to resolve AWS credentials from any provider in the chain. Tried: " + missing + + detectedButMissingHints()); + } + + private String detectedButMissingHints() { + StringBuilder hints = new StringBuilder(); + for (BuiltinProvider slot : BuiltinProvider.values()) { + if (slot.moduleSuggestion() != null && slot.isDetected()) { + if (!isClaimed(slot)) { + hints.append(" Detected ") + .append(slot.name()) + .append(" credentials; add '") + .append(slot.moduleSuggestion()) + .append("' to your dependencies."); + } + } + } + return hints.toString(); + } + + private boolean isClaimed(BuiltinProvider slot) { + for (NamedResolver nr : resolvers) { + if (nr.name.equals(slot.name().toLowerCase(Locale.ROOT))) { + return true; + } + } + return false; + } + + /** + * @return the ordered list of provider names in this chain. + */ + public List providerNames() { + List names = new ArrayList<>(resolvers.size()); + for (NamedResolver nr : resolvers) { + names.add(nr.name); + } + return names; + } + + @Override + public void invalidate() { + for (NamedResolver nr : resolvers) { + nr.resolver.invalidate(); + } + } + + @Override + public void close() { + executor.shutdownNow(); + } + + private static String resolveName(String target, Map byName) { + AwsCredentialProvider resolved = byName.get(target); + return resolved != null ? resolved.name() : target; + } + + private record NamedResolver(String name, AwsCredentialsResolver resolver) {} +} diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialProvider.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialProvider.java new file mode 100644 index 000000000..214adedb9 --- /dev/null +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialProvider.java @@ -0,0 +1,41 @@ +/* + * 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.List; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsResolver; + +/** + * SPI for registering a credential provider into the AWS default credential chain. + */ +public interface AwsCredentialProvider { + /** + * @return the unique name of this provider (for example {@code "environment"}, {@code "profile"}, {@code "imds"}). + */ + String name(); + + /** + * @return alternative names for the provider. + */ + default List aliases() { + return List.of(); + } + + /** + * @return the ordering constraint for this provider. + */ + OrderingConstraint ordering(); + + /** + * Create the credential resolver for this provider. + * + *

Called once during chain assembly. The returned resolver is used for the lifetime of the chain. + * + * @param context shared resources provided by the chain. + * @return the resolver. + */ + AwsCredentialsResolver create(ProviderContext context); +} diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/BuiltinProvider.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/BuiltinProvider.java new file mode 100644 index 000000000..3cfbdb566 --- /dev/null +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/BuiltinProvider.java @@ -0,0 +1,106 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.chain; + +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Builtin credential provider slots in the AWS default credential chain. + * + *

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

Each slot knows how to cheaply detect whether credentials of that type are likely available + * (via {@link #isDetected()}), and what dependency to suggest if the implementation is missing + * (via {@link #moduleSuggestion()}). + */ +public enum BuiltinProvider { + /** Credentials explicitly provided in code. */ + CODE(null) { + @Override + public boolean isDetected() { + return false; + } + }, + + /** Credentials from JVM system properties ({@code aws.accessKeyId}, etc.). */ + JAVA_SYSTEM_PROPERTIES("software.amazon.smithy.java:aws-client-core") { + @Override + public boolean isDetected() { + return System.getProperty("aws.accessKeyId") != null; + } + }, + + /** Credentials from environment variables ({@code AWS_ACCESS_KEY_ID}, etc.). */ + ENVIRONMENT("software.amazon.smithy.java:aws-client-core") { + @Override + public boolean isDetected() { + return System.getenv("AWS_ACCESS_KEY_ID") != null; + } + }, + + /** Web identity token from environment variables ({@code AWS_WEB_IDENTITY_TOKEN_FILE} + {@code AWS_ROLE_ARN}). */ + WEB_IDENTITY_TOKEN_ENV("software.amazon.smithy.java:aws-credentials-sts") { + @Override + public boolean isDetected() { + return System.getenv("AWS_WEB_IDENTITY_TOKEN_FILE") != null && System.getenv("AWS_ROLE_ARN") != null; + } + }, + + /** Credentials from the AWS shared config/credentials files. */ + SHARED_CONFIG("software.amazon.smithy.java:aws-config") { + @Override + public boolean isDetected() { + var home = System.getProperty("user.home"); + if (home == null) { + return false; + } + var awsDir = Path.of(home, ".aws"); + return Files.exists(awsDir.resolve("credentials")) || Files.exists(awsDir.resolve("config")); + } + }, + + /** Credentials from an HTTP endpoint (ECS container, EKS pod identity, etc.). */ + ECS_CONTAINER("software.amazon.smithy.java:aws-credentials-ecs") { + @Override + public boolean isDetected() { + return System.getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != null + || System.getenv("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI") != null; + } + }, + + /** Credentials from EC2 instance metadata service (IMDS). */ + EC2_INSTANCE_METADATA("software.amazon.smithy.java:aws-credentials-imds") { + @Override + public boolean isDetected() { + // No cheap signal; IMDS requires a network call to detect. + return false; + } + }; + + private final String moduleSuggestion; + + BuiltinProvider(String moduleSuggestion) { + this.moduleSuggestion = moduleSuggestion; + } + + /** + * Cheaply detect whether this credential source is likely available in the current environment. + * This must not perform network calls or expensive I/O. + * + * @return {@code true} if signals suggest this source is configured. + */ + public abstract boolean isDetected(); + + /** + * @return the Maven coordinate to suggest when this source is detected but no implementation + * is on the classpath, or {@code null} if no suggestion is available. + */ + public String moduleSuggestion() { + return moduleSuggestion; + } +} diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/OrderingConstraint.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/OrderingConstraint.java new file mode 100644 index 000000000..9ed564cfe --- /dev/null +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/OrderingConstraint.java @@ -0,0 +1,50 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.chain; + +/** + * Describes where an {@link AwsCredentialProvider} sits in the credential chain. + */ +public sealed interface OrderingConstraint { + /** + * Claims a builtin slot in the default chain. Only one provider may claim each slot. + * + * @param slot the builtin slot to claim. + */ + record Builtin(BuiltinProvider slot) implements OrderingConstraint { + public Builtin { + if (slot == null) { + throw new IllegalArgumentException("slot must not be null"); + } + } + } + + /** + * Positions a provider immediately before the named provider. + * + * @param provider the name of the provider this one must come before. + */ + record Before(String provider) implements OrderingConstraint { + public Before { + if (provider == null || provider.isEmpty()) { + throw new IllegalArgumentException("provider must not be null or empty"); + } + } + } + + /** + * Positions a provider immediately after the named provider. + * + * @param provider the name of the provider this one must come after. + */ + record After(String provider) implements OrderingConstraint { + public After { + if (provider == null || provider.isEmpty()) { + throw new IllegalArgumentException("provider must not be null or empty"); + } + } + } +} diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ProviderContext.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ProviderContext.java new file mode 100644 index 000000000..c896bb0b5 --- /dev/null +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ProviderContext.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.credentials.chain; + +import java.util.concurrent.ScheduledExecutorService; + +/** + * Context passed to {@link AwsCredentialProvider#create(ProviderContext)} during chain assembly. + * + *

Carries shared resources that providers may use. + * + * @param executor Possibly null custom executor for resolving credentials. + */ +public record ProviderContext(ScheduledExecutorService executor) {} diff --git a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java new file mode 100644 index 000000000..39c120b63 --- /dev/null +++ b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java @@ -0,0 +1,176 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.chain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.java.auth.api.identity.IdentityResult; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsResolver; +import software.amazon.smithy.java.context.Context; + +class AwsCredentialChainTest { + @Test + void builtinProvidersAreOrderedByEnumOrder() { + var chain = AwsCredentialChain.assemble(List.of( + registration("imds", + new OrderingConstraint.Builtin(BuiltinProvider.EC2_INSTANCE_METADATA), + errorResolver("imds")), + registration("env", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("env")), + registration("profile", + new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), + errorResolver("profile")))); + + assertEquals(List.of("env", "profile", "imds"), chain.providerNames()); + } + + @Test + void firstSuccessfulProviderWins() { + var chain = AwsCredentialChain.assemble(List.of( + registration("env", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("env")), + registration("profile", + new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), + staticResolver("AK", "SK")))); + IdentityResult result = chain.resolveIdentity(Context.empty()); + + assertNotNull(result.identity()); + assertEquals("AK", result.identity().accessKeyId()); + } + + @Test + void allFailReturnsAggregatedError() { + var chain = AwsCredentialChain.assemble(List.of( + registration("env", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("no env")), + registration("profile", + new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), + errorResolver("no profile")))); + IdentityResult result = chain.resolveIdentity(Context.empty()); + + assertNull(result.identity()); + assertTrue(result.error().contains("no env")); + assertTrue(result.error().contains("no profile")); + } + + @Test + void duplicateSlotThrows() { + assertThrows(IllegalStateException.class, + () -> AwsCredentialChain.assemble(List.of( + registration("a", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("a")), + registration("b", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("b"))))); + } + + @Test + void relativeAfterInsertsCorrectly() { + var chain = AwsCredentialChain.assemble(List.of( + registration("env", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("env")), + registration("profile", + new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), + errorResolver("profile")), + registration("custom", + new OrderingConstraint.After("env"), + errorResolver("custom")))); + + assertEquals(List.of("env", "custom", "profile"), chain.providerNames()); + } + + @Test + void relativeBeforeInsertsCorrectly() { + var chain = AwsCredentialChain.assemble(List.of( + registration("env", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("env")), + registration("profile", + new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), + errorResolver("profile")), + registration("custom", + new OrderingConstraint.Before("profile"), + errorResolver("custom")))); + + assertEquals(List.of("env", "custom", "profile"), chain.providerNames()); + } + + @Test + void relativeWithUnknownReferenceThrows() { + assertThrows(IllegalStateException.class, + () -> AwsCredentialChain.assemble(List.of( + registration("env", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("env")), + registration("custom", + new OrderingConstraint.After("nonexistent"), + errorResolver("custom"))))); + } + + @Test + void duplicateNameThrows() { + assertThrows(IllegalStateException.class, + () -> AwsCredentialChain.assemble(List.of( + registration("env", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("env")), + registration("env", + new OrderingConstraint.Builtin(BuiltinProvider.JAVA_SYSTEM_PROPERTIES), + errorResolver("env2"))))); + } + + @Test + void emptyChainReturnsDescriptiveError() { + var chain = AwsCredentialChain.assemble(List.of()); + IdentityResult result = chain.resolveIdentity(Context.empty()); + + assertNull(result.identity()); + assertTrue(result.error().contains("No credential providers were discovered")); + } + + private static AwsCredentialProvider registration( + String name, + OrderingConstraint ordering, + AwsCredentialsResolver resolver + ) { + return new AwsCredentialProvider() { + @Override + public String name() { + return name; + } + + @Override + public OrderingConstraint ordering() { + return ordering; + } + + @Override + public AwsCredentialsResolver create(ProviderContext context) { + return resolver; + } + }; + } + + private static AwsCredentialsResolver errorResolver(String msg) { + return ctx -> IdentityResult.ofError(AwsCredentialChainTest.class, msg); + } + + private static AwsCredentialsResolver staticResolver(String ak, String sk) { + return ctx -> IdentityResult.of(AwsCredentialsIdentity.create(ak, sk)); + } +} diff --git a/aws/client/aws-client-core/build.gradle.kts b/aws/client/aws-client-core/build.gradle.kts index 9d388006b..4a89e0f3d 100644 --- a/aws/client/aws-client-core/build.gradle.kts +++ b/aws/client/aws-client-core/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { api(project(":client:client-core")) api(project(":aws:aws-auth-api")) api(project(":auth-api")) + implementation(project(":aws:aws-credential-chain")) } tasks { diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/AwsCredentialChainPlugin.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/AwsCredentialChainPlugin.java new file mode 100644 index 000000000..128dd76e7 --- /dev/null +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/AwsCredentialChainPlugin.java @@ -0,0 +1,62 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.client.core; + +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.credentials.chain.AwsCredentialChain; +import software.amazon.smithy.java.client.core.ClientConfig; +import software.amazon.smithy.java.client.core.ClientPlugin; +import software.amazon.smithy.java.client.core.auth.scheme.AuthScheme; + +/** + * A {@link ClientPlugin} that registers the AWS default credential chain on any client that uses an AWS auth scheme + * (one whose {@link AuthScheme#identityClass()} is {@link AwsCredentialsIdentity}). + * + *

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

Users can also add it explicitly: + *

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

To customize the chain (e.g., exclude providers), build the chain manually and register it as an identity + * resolver directly instead of using this plugin. + */ +public final class AwsCredentialChainPlugin implements ClientPlugin { + @Override + public Phase getPluginPhase() { + return Phase.DEFAULTS; + } + + @Override + public void configureClient(ClientConfig.Builder config) { + if (needsAwsCredentials(config) && !hasAwsCredentialsResolver(config)) { + config.addIdentityResolver(AwsCredentialChain.create()); + } + } + + private static boolean needsAwsCredentials(ClientConfig.Builder config) { + for (AuthScheme scheme : config.supportedAuthSchemes()) { + if (scheme.identityClass() == AwsCredentialsIdentity.class) { + return true; + } + } + return false; + } + + private static boolean hasAwsCredentialsResolver(ClientConfig.Builder config) { + for (IdentityResolver resolver : config.identityResolvers()) { + if (resolver.identityType() == AwsCredentialsIdentity.class) { + return true; + } + } + return false; + } +} diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java new file mode 100644 index 000000000..b284d6e6f --- /dev/null +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.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.client.core.identity; + +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsResolver; +import software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider; +import software.amazon.smithy.java.aws.credentials.chain.BuiltinProvider; +import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; +import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; + +/** + * Registers {@link EnvironmentVariableIdentityResolver} in the credential chain's + * {@link BuiltinProvider#ENVIRONMENT} slot. + */ +public final class EnvironmentCredentialProvider implements AwsCredentialProvider { + @Override + public String name() { + return "Environment"; + } + + @Override + public OrderingConstraint ordering() { + return new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT); + } + + @Override + public AwsCredentialsResolver create(ProviderContext context) { + return EnvironmentVariableIdentityResolver.INSTANCE; + } +} diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentVariableIdentityResolver.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentVariableIdentityResolver.java index 93b788411..a9419f644 100644 --- a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentVariableIdentityResolver.java +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentVariableIdentityResolver.java @@ -13,8 +13,10 @@ /** * {@link AwsCredentialsResolver} implementation that loads credentials from environment variables. * - *

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

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

Expected environment variables: *

*
{@code AWS_ACCESS_KEY_ID}
*
Sets the AWS Access Key for the identity
@@ -22,6 +24,8 @@ *
Sets the AWS Secret Key for the identity
*
{@code AWS_SESSION_TOKEN}
*
(optional) Security token provided by the AWS Security Token Service (STS) for temporary credentials
+ *
{@code AWS_ACCOUNT_ID}
+ *
(optional) AWS account ID
*
*/ public final class EnvironmentVariableIdentityResolver implements AwsCredentialsResolver { @@ -30,19 +34,40 @@ public final class EnvironmentVariableIdentityResolver implements AwsCredentials private static final String ACCESS_KEY_PROPERTY = "AWS_ACCESS_KEY_ID"; private static final String SECRET_KEY_PROPERTY = "AWS_SECRET_ACCESS_KEY"; private static final String SESSION_TOKEN_PROPERTY = "AWS_SESSION_TOKEN"; - private static final String ERROR_MESSAGE = "Could not resolve an AWS identity using the AWS_ACCESS_KEY_ID and " - + "AWS_SECRET_ACCESS_KEY environment variables"; + private static final String ACCOUNT_ID_PROPERTY = "AWS_ACCOUNT_ID"; + private static final IdentityResult NOT_FOUND = IdentityResult.ofError( + EnvironmentVariableIdentityResolver.class, + "Could not resolve an AWS identity using the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment " + + "variables"); + + private volatile IdentityResult cached; @Override public IdentityResult resolveIdentity(Context requestProperties) { + IdentityResult result = cached; + if (result != null) { + return result; + } + + result = resolve(); + cached = result; + return result; + } + + @Override + public void invalidate() { + cached = null; + } + + private static IdentityResult resolve() { String accessKey = System.getenv(ACCESS_KEY_PROPERTY); String secretKey = System.getenv(SECRET_KEY_PROPERTY); - String sessionToken = System.getenv(SESSION_TOKEN_PROPERTY); - if (accessKey == null || secretKey == null) { - return IdentityResult.ofError(getClass(), ERROR_MESSAGE); + return NOT_FOUND; } - return IdentityResult.of(AwsCredentialsIdentity.create(accessKey, secretKey, sessionToken)); + String sessionToken = System.getenv(SESSION_TOKEN_PROPERTY); + String accountId = System.getenv(ACCOUNT_ID_PROPERTY); + return IdentityResult.of(AwsCredentialsIdentity.create(accessKey, secretKey, sessionToken, null, accountId)); } } diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java new file mode 100644 index 000000000..ea0b8ecfb --- /dev/null +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.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.client.core.identity; + +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsResolver; +import software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider; +import software.amazon.smithy.java.aws.credentials.chain.BuiltinProvider; +import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; +import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; + +/** + * Registers {@link SystemPropertiesIdentityResolver} in the credential chain's + * {@link BuiltinProvider#JAVA_SYSTEM_PROPERTIES} slot. + */ +public final class SystemPropertiesCredentialProvider implements AwsCredentialProvider { + @Override + public String name() { + return "JavaSystemProperties"; + } + + @Override + public OrderingConstraint ordering() { + return new OrderingConstraint.Builtin(BuiltinProvider.JAVA_SYSTEM_PROPERTIES); + } + + @Override + public AwsCredentialsResolver create(ProviderContext context) { + return SystemPropertiesIdentityResolver.INSTANCE; + } +} diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesIdentityResolver.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesIdentityResolver.java index 59e3ddbca..c6b4008ac 100644 --- a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesIdentityResolver.java +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesIdentityResolver.java @@ -13,8 +13,10 @@ /** * {@link AwsCredentialsResolver} implementation that loads credentials from Java system properties. * - *

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

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

Expected system properties: *

*
{@code aws.accessKeyId}
*
Sets the AWS Access Key for the identity
@@ -22,6 +24,8 @@ *
Sets the AWS Secret Key for the identity
*
{@code aws.sessionToken}
*
(optional) Security token provided by the AWS Security Token Service (STS) for temporary credentials
+ *
{@code aws.accountId}
+ *
(optional) AWS account ID
*
* * @see Java System Properties @@ -32,19 +36,39 @@ public final class SystemPropertiesIdentityResolver implements AwsCredentialsRes private static final String ACCESS_KEY_PROPERTY = "aws.accessKeyId"; private static final String SECRET_KEY_PROPERTY = "aws.secretAccessKey"; private static final String SESSION_TOKEN_PROPERTY = "aws.sessionToken"; - private static final String ERROR_MESSAGE = "Could not resolve AWS identity from the aws.accessKeyId and " - + "aws.secretAccessKey system properties"; + private static final String ACCOUNT_ID_PROPERTY = "aws.accountId"; + private static final IdentityResult NOT_FOUND = IdentityResult.ofError( + SystemPropertiesIdentityResolver.class, + "Could not resolve AWS identity from the aws.accessKeyId and aws.secretAccessKey system properties"); + + private volatile IdentityResult cached; @Override public IdentityResult resolveIdentity(Context requestProperties) { + IdentityResult result = cached; + if (result != null) { + return result; + } + + result = resolve(); + cached = result; + return result; + } + + @Override + public void invalidate() { + cached = null; + } + + private static IdentityResult resolve() { String accessKey = System.getProperty(ACCESS_KEY_PROPERTY); String secretKey = System.getProperty(SECRET_KEY_PROPERTY); - String sessionToken = System.getProperty(SESSION_TOKEN_PROPERTY); - - if (accessKey != null && secretKey != null) { - return IdentityResult.of(AwsCredentialsIdentity.create(accessKey, secretKey, sessionToken)); + if (accessKey == null || secretKey == null) { + return NOT_FOUND; } - return IdentityResult.ofError(getClass(), ERROR_MESSAGE); + String sessionToken = System.getProperty(SESSION_TOKEN_PROPERTY); + String accountId = System.getProperty(ACCOUNT_ID_PROPERTY); + return IdentityResult.of(AwsCredentialsIdentity.create(accessKey, secretKey, sessionToken, null, accountId)); } } diff --git a/aws/client/aws-client-core/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider b/aws/client/aws-client-core/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider new file mode 100644 index 000000000..633bd6170 --- /dev/null +++ b/aws/client/aws-client-core/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider @@ -0,0 +1,2 @@ +software.amazon.smithy.java.aws.client.core.identity.EnvironmentCredentialProvider +software.amazon.smithy.java.aws.client.core.identity.SystemPropertiesCredentialProvider diff --git a/settings.gradle.kts b/settings.gradle.kts index 5c86e4f7a..e12b1b2ea 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -86,6 +86,8 @@ include(":aws:client:aws-client-rulesengine") include(":aws:integrations:aws-lambda-endpoint") include(":aws:server:aws-server-restjson") include(":aws:aws-auth-api") +include(":aws:aws-config") +include(":aws:aws-credential-chain") // AWS service bundling code include(":aws:aws-service-bundle") From b03d71260036738be861b82974cffcef23de9fa5 Mon Sep 17 00:00:00 2001 From: Michael Dowling Date: Fri, 8 May 2026 09:42:17 -0500 Subject: [PATCH 02/13] Ensure config credentials are cached --- .../java/aws/config/ProfileCredentialProvider.java | 10 +++++++--- .../java/aws/credentials/chain/AwsCredentialChain.java | 3 ++- .../aws/credentials/chain/AwsCredentialProvider.java | 5 +++-- .../core/identity/EnvironmentCredentialProvider.java | 5 +++-- .../identity/SystemPropertiesCredentialProvider.java | 5 +++-- 5 files changed, 18 insertions(+), 10 deletions(-) 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 index dbab3d81f..2c11a0b3f 100644 --- 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 @@ -6,7 +6,9 @@ package software.amazon.smithy.java.aws.config; import java.util.List; -import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsResolver; +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; @@ -33,7 +35,9 @@ public OrderingConstraint ordering() { } @Override - public AwsCredentialsResolver create(ProviderContext context) { - return AwsProfileCredentialsResolver.builder().build(); + public IdentityResolver create(ProviderContext context) { + return CachingIdentityResolver.builder(AwsProfileCredentialsResolver.builder().build()) + .executor(context.executor()) + .build(); } } 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 index a5ed904c0..f4937e1d6 100644 --- 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 @@ -15,6 +15,7 @@ import java.util.ServiceLoader; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; +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; @@ -242,5 +243,5 @@ private static String resolveName(String target, Map resolver) {} } diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialProvider.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialProvider.java index 214adedb9..d056e7044 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialProvider.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialProvider.java @@ -6,7 +6,8 @@ package software.amazon.smithy.java.aws.credentials.chain; import java.util.List; -import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsResolver; +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; /** * SPI for registering a credential provider into the AWS default credential chain. @@ -37,5 +38,5 @@ default List aliases() { * @param context shared resources provided by the chain. * @return the resolver. */ - AwsCredentialsResolver create(ProviderContext context); + IdentityResolver create(ProviderContext context); } diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java index b284d6e6f..6e7608e8e 100644 --- a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java @@ -5,7 +5,8 @@ package software.amazon.smithy.java.aws.client.core.identity; -import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsResolver; +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; @@ -27,7 +28,7 @@ public OrderingConstraint ordering() { } @Override - public AwsCredentialsResolver create(ProviderContext context) { + public IdentityResolver create(ProviderContext context) { return EnvironmentVariableIdentityResolver.INSTANCE; } } diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java index ea0b8ecfb..0541c0fc7 100644 --- a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java @@ -5,7 +5,8 @@ package software.amazon.smithy.java.aws.client.core.identity; -import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsResolver; +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; @@ -27,7 +28,7 @@ public OrderingConstraint ordering() { } @Override - public AwsCredentialsResolver create(ProviderContext context) { + public IdentityResolver create(ProviderContext context) { return SystemPropertiesIdentityResolver.INSTANCE; } } From ad97df2aebc299df30094c316e4f974bcc6dd5ae Mon Sep 17 00:00:00 2001 From: Michael Dowling Date: Fri, 8 May 2026 13:20:48 -0500 Subject: [PATCH 03/13] Add support for IMDS --- .../java/aws/config/AwsProfileFile.java | 4 + .../aws/config/ProfileCredentialProvider.java | 5 +- .../credentials/chain/AwsCredentialChain.java | 2 +- .../credentials/chain/ProviderContext.java | 13 +- aws/aws-credentials-imds/build.gradle.kts | 19 + .../java/aws/credentials/imds/ImdsClient.java | 189 +++++ .../imds/ImdsCredentialProvider.java | 190 +++++ ...ws.credentials.chain.AwsCredentialProvider | 1 + .../aws/credentials/imds/ImdsClientTest.java | 144 ++++ .../credentials/imds/ImdsConformanceTest.java | 205 ++++++ .../aws/credentials/imds/imds-v21-tests.json | 653 ++++++++++++++++++ settings.gradle.kts | 1 + 12 files changed, 1421 insertions(+), 5 deletions(-) create mode 100644 aws/aws-credentials-imds/build.gradle.kts create mode 100644 aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsClient.java create mode 100644 aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java create mode 100644 aws/aws-credentials-imds/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider create mode 100644 aws/aws-credentials-imds/src/test/java/software/amazon/smithy/java/aws/credentials/imds/ImdsClientTest.java create mode 100644 aws/aws-credentials-imds/src/test/java/software/amazon/smithy/java/aws/credentials/imds/ImdsConformanceTest.java create mode 100644 aws/aws-credentials-imds/src/test/resources/software/amazon/smithy/java/aws/credentials/imds/imds-v21-tests.json 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 index 2a83719fc..f68a78fe5 100644 --- 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 @@ -19,6 +19,7 @@ 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; /** @@ -66,6 +67,9 @@ public final class AwsProfileFile { /** 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; 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 index 2c11a0b3f..ea5b2148d 100644 --- 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 @@ -36,7 +36,10 @@ public OrderingConstraint ordering() { @Override public IdentityResolver create(ProviderContext context) { - return CachingIdentityResolver.builder(AwsProfileCredentialsResolver.builder().build()) + 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-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 index f4937e1d6..bfbf5fb54 100644 --- 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 @@ -99,7 +99,7 @@ static AwsCredentialChain assemble(List registrations) { t.setDaemon(true); return t; }); - ProviderContext ctx = new ProviderContext(executor); + ProviderContext ctx = new ProviderContext(executor, Context.create()); // Build the ordered list: builtin slots first (in enum order), then insert relatives. List ordered = new ArrayList<>(); diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ProviderContext.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ProviderContext.java index c896bb0b5..9957e8db1 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ProviderContext.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ProviderContext.java @@ -6,12 +6,19 @@ package software.amazon.smithy.java.aws.credentials.chain; import java.util.concurrent.ScheduledExecutorService; +import software.amazon.smithy.java.context.Context; /** * Context passed to {@link AwsCredentialProvider#create(ProviderContext)} during chain assembly. * - *

Carries shared resources that providers may use. + *

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

    + *
  • A shared {@link ScheduledExecutorService} for background credential refresh tasks.
  • + *
  • A {@link Context} property bag for sharing data between providers (e.g., a parsed + * config file).
  • + *
* - * @param executor Possibly null custom executor for resolving credentials. + * @param executor shared executor for background refresh. + * @param properties shared property bag for cross-provider data. */ -public record ProviderContext(ScheduledExecutorService executor) {} +public record ProviderContext(ScheduledExecutorService executor, Context properties) {} diff --git a/aws/aws-credentials-imds/build.gradle.kts b/aws/aws-credentials-imds/build.gradle.kts new file mode 100644 index 000000000..554978572 --- /dev/null +++ b/aws/aws-credentials-imds/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id("smithy-java.module-conventions") +} + +description = "This module provides an IMDS-based credential provider for EC2 instances." + +extra["displayName"] = "Smithy :: Java :: AWS :: Credentials :: IMDS" +extra["moduleName"] = "software.amazon.smithy.java.aws.credentials.imds" + +dependencies { + api(project(":aws:aws-auth-api")) + api(project(":auth-api")) + implementation(project(":aws:aws-credential-chain")) + implementation(project(":aws:aws-config")) + implementation(project(":logging")) + implementation(project(":codecs:json-codec")) + + testImplementation(project(":core")) +} diff --git a/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsClient.java b/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsClient.java new file mode 100644 index 000000000..c089af50d --- /dev/null +++ b/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsClient.java @@ -0,0 +1,189 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.imds; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.Instant; +import software.amazon.smithy.java.logging.InternalLogger; + +/** + * Minimal IMDSv2 client. Handles token acquisition, caching, and retries with exponential backoff. + */ +final class ImdsClient { + + private static final InternalLogger LOGGER = InternalLogger.getLogger(ImdsClient.class); + private static final String TOKEN_PATH = "/latest/api/token"; + private static final String CREDENTIALS_EXTENDED_PATH = "/latest/meta-data/iam/security-credentials-extended"; + private static final String CREDENTIALS_LEGACY_PATH = "/latest/meta-data/iam/security-credentials"; + private static final Duration TOKEN_TTL = Duration.ofSeconds(21600); + private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(1); + private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(5); + private static final int MAX_RETRIES = 3; + + private final URI endpoint; + private final SimpleHttpClient httpClient; + private volatile String cachedToken; + private volatile Instant tokenExpiry; + private volatile boolean apiVersionKnown = false; + private volatile boolean useLegacyPath = false; + private volatile String cachedProfileName; + + /** Minimal HTTP client interface for testability. */ + @FunctionalInterface + interface SimpleHttpClient { + HttpResponse send(HttpRequest request) throws IOException, InterruptedException; + } + + @FunctionalInterface + private interface RetryableCall { + String execute(T ctx) throws IOException, InterruptedException; + } + + ImdsClient(URI endpoint) { + this(endpoint, defaultClient()); + } + + ImdsClient(URI endpoint, SimpleHttpClient httpClient) { + this.endpoint = endpoint; + this.httpClient = httpClient; + } + + private static SimpleHttpClient defaultClient() { + HttpClient client = HttpClient.newBuilder().connectTimeout(CONNECT_TIMEOUT).build(); + return request -> client.send(request, HttpResponse.BodyHandlers.ofString()); + } + + /** + * Fetch credentials JSON from IMDS. + */ + String fetchCredentials(String profileName) throws IOException, InterruptedException { + String resolvedProfile = profileName; + if (resolvedProfile == null) { + resolvedProfile = discoverProfileName(); + } + + String basePath = useLegacyPath ? CREDENTIALS_LEGACY_PATH : CREDENTIALS_EXTENDED_PATH; + String body = retryGet(basePath + "/" + resolvedProfile); + + // 404: could be API version mismatch or profile change. + if (body == null && !useLegacyPath && !apiVersionKnown) { + // API version unknown and extended returned 404 — try legacy. + useLegacyPath = true; + if (profileName == null) { + cachedProfileName = null; + resolvedProfile = discoverProfileName(); + } + body = retryGet(CREDENTIALS_LEGACY_PATH + "/" + resolvedProfile); + } else if (body == null && profileName == null) { + // API version known but profile returned 404 — profile changed, re-discover. + cachedProfileName = null; + resolvedProfile = discoverProfileName(); + basePath = useLegacyPath ? CREDENTIALS_LEGACY_PATH : CREDENTIALS_EXTENDED_PATH; + body = retryGet(basePath + "/" + resolvedProfile); + } + + if (body == null) { + throw new IOException("Failed to fetch IMDS credentials after retries"); + } + + return body; + } + + private String discoverProfileName() throws IOException, InterruptedException { + String cached = cachedProfileName; + if (cached != null) { + return cached; + } + String basePath = useLegacyPath ? CREDENTIALS_LEGACY_PATH : CREDENTIALS_EXTENDED_PATH; + String body = retryGet(basePath); + if (body == null && !useLegacyPath) { + useLegacyPath = true; + body = retryGet(CREDENTIALS_LEGACY_PATH); + } + if (body == null) { + throw new IOException("Failed to discover IMDS instance profile name"); + } + apiVersionKnown = true; + cachedProfileName = body.strip(); + return cachedProfileName; + } + + private String retryGet(String path) throws IOException, InterruptedException { + return retry(path, p -> { + String token = getToken(); + HttpRequest request = HttpRequest.newBuilder() + .uri(endpoint.resolve(p)) + .header("X-aws-ec2-metadata-token", token) + .timeout(REQUEST_TIMEOUT) + .GET() + .build(); + HttpResponse response = httpClient.send(request); + if (response.statusCode() == 200) { + return response.body(); + } else if (response.statusCode() == 404) { + return null; + } else { + throw new IOException("IMDS returned status " + response.statusCode() + " for " + p); + } + }); + } + + private String getToken() throws IOException, InterruptedException { + String token = cachedToken; + if (token != null && tokenExpiry != null && Instant.now().isBefore(tokenExpiry)) { + return token; + } + + HttpRequest request = HttpRequest.newBuilder() + .uri(endpoint.resolve(TOKEN_PATH)) + .header("X-aws-ec2-metadata-token-ttl-seconds", String.valueOf(TOKEN_TTL.toSeconds())) + .timeout(REQUEST_TIMEOUT) + .PUT(HttpRequest.BodyPublishers.noBody()) + .build(); + + String newToken = retry(request, r -> { + HttpResponse response = httpClient.send(r); + if (response.statusCode() == 200) { + return response.body(); + } + throw new IOException("IMDS token request returned status " + response.statusCode()); + }); + + if (newToken == null) { + throw new IOException("Failed to acquire IMDSv2 token after retries"); + } else { + cachedToken = newToken; + tokenExpiry = Instant.now().plus(TOKEN_TTL); + return newToken; + } + } + + /** + * Retry a callable with exponential backoff. Returns null if the callable returns null (e.g., 404). + * Throws the last IOException if all retries are exhausted. + */ + private static String retry(T ctx, RetryableCall call) throws IOException, InterruptedException { + IOException lastException = null; + for (int attempt = 0; attempt <= MAX_RETRIES; attempt++) { + if (attempt > 0) { + Thread.sleep((long) Math.pow(2, attempt - 1) * 100); + } + try { + return call.execute(ctx); + } catch (IOException e) { + LOGGER.debug("IMDS request failed (attempt {}): {}", attempt + 1, e.getMessage()); + lastException = e; + } + } + + throw lastException; + } +} diff --git a/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java b/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java new file mode 100644 index 000000000..d6b52de98 --- /dev/null +++ b/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java @@ -0,0 +1,190 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.imds; + +import java.io.IOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import software.amazon.smithy.java.auth.api.identity.CachingIdentityResolver; +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; +import software.amazon.smithy.java.auth.api.identity.IdentityResult; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsResolver; +import software.amazon.smithy.java.aws.config.AwsProfile; +import software.amazon.smithy.java.aws.config.AwsProfileFile; +import software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider; +import software.amazon.smithy.java.aws.credentials.chain.BuiltinProvider; +import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; +import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; +import software.amazon.smithy.java.context.Context; +import software.amazon.smithy.java.core.serde.document.Document; +import software.amazon.smithy.java.json.JsonCodec; +import software.amazon.smithy.java.logging.InternalLogger; + +/** + * Credential provider that fetches credentials from the EC2 Instance Metadata Service (IMDS). + * + *

Registers in the {@link BuiltinProvider#EC2_INSTANCE_METADATA} chain slot. Uses IMDSv2 exclusively + * (no v1 fallback). Credentials are cached with static stability enabled per the AWS Static Stability SEP. + */ +public final class ImdsCredentialProvider implements AwsCredentialProvider { + + private static final InternalLogger LOGGER = InternalLogger.getLogger(ImdsCredentialProvider.class); + private static final URI DEFAULT_ENDPOINT = URI.create("http://169.254.169.254"); + private static final JsonCodec CODEC = JsonCodec.builder().build(); + + @Override + public String name() { + return "Ec2InstanceMetadata"; + } + + @Override + public OrderingConstraint ordering() { + return new OrderingConstraint.Builtin(BuiltinProvider.EC2_INSTANCE_METADATA); + } + + @Override + public IdentityResolver create(ProviderContext context) { + AwsProfileFile profileFile = context.properties().get(AwsProfileFile.CONTEXT_KEY); + if (isDisabled(profileFile)) { + return new DisabledResolver(); + } + + URI endpoint = resolveEndpoint(); + String profileName = resolveProfileName(profileFile); + ImdsClient client = new ImdsClient(endpoint); + AwsCredentialsResolver delegate = ctx -> fetchAndParse(client, profileName); + + return CachingIdentityResolver.builder(delegate) + .executor(context.executor()) + .allowExpiredCredentials(true) // Static stability + .build(); + } + + private static IdentityResult fetchAndParse(ImdsClient client, String profileName) { + String json; + try { + json = client.fetchCredentials(profileName); + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + return IdentityResult.ofError(ImdsCredentialProvider.class, + "Failed to fetch credentials from IMDS: " + e.getMessage()); + } + + Document doc = CODEC.createDeserializer(json.getBytes(StandardCharsets.UTF_8)).readDocument(); + String code = stringMember(doc, "Code"); + if (!"Success".equals(code)) { + return IdentityResult.ofError(ImdsCredentialProvider.class, "IMDS returned non-success code: " + code); + } + + String accessKeyId = stringMember(doc, "AccessKeyId"); + String secretAccessKey = stringMember(doc, "SecretAccessKey"); + if (accessKeyId == null || secretAccessKey == null) { + return IdentityResult.ofError(ImdsCredentialProvider.class, + "IMDS response missing AccessKeyId or SecretAccessKey"); + } + + String sessionToken = stringMember(doc, "Token"); + String accountId = stringMember(doc, "AccountId"); + String expirationStr = stringMember(doc, "Expiration"); + Instant expiration = null; + if (expirationStr != null) { + try { + expiration = Instant.parse(expirationStr); + } catch (DateTimeParseException e) { + LOGGER.warn("IMDS returned unparseable Expiration: {}", expirationStr); + } + } + + return IdentityResult.of(AwsCredentialsIdentity.create( + accessKeyId, + secretAccessKey, + sessionToken, + expiration, + accountId)); + } + + private static String stringMember(Document doc, String name) { + Document member = doc.getMember(name); + return member == null ? null : member.asString(); + } + + private static boolean isDisabled(AwsProfileFile profileFile) { + // Priority: system property > env var > config file. First non-null value wins. + String value = System.getProperty("aws.disableEc2Metadata"); + if (value == null) { + value = System.getenv("AWS_EC2_METADATA_DISABLED"); + } + if (value == null) { + value = getProfileProperty(profileFile, "disable_ec2_metadata"); + } + return "true".equalsIgnoreCase(value); + } + + private static URI resolveEndpoint() { + String override = System.getenv("AWS_EC2_METADATA_SERVICE_ENDPOINT"); + return override != null && !override.isEmpty() ? URI.create(override) : DEFAULT_ENDPOINT; + } + + private static String resolveProfileName(AwsProfileFile profileFile) { + // Priority: system property > env var > config file + String prop = System.getProperty("aws.ec2InstanceProfileName"); + if (prop != null) { + return ensureNotBlank("aws.ec2InstanceProfileName", prop); + } + + String env = System.getenv("AWS_EC2_INSTANCE_PROFILE_NAME"); + if (env != null) { + return ensureNotBlank("AWS_EC2_INSTANCE_PROFILE_NAME", env); + } + + String config = getProfileProperty(profileFile, "ec2_instance_profile_name"); + if (config != null) { + return ensureNotBlank("ec2_instance_profile_name", config); + } + + // Will be discovered from IMDS. + return null; + } + + private static String ensureNotBlank(String name, String value) { + if (value.isBlank()) { + throw new IllegalStateException(name + " is set but blank"); + } + return value; + } + + private static String getProfileProperty(AwsProfileFile profileFile, String key) { + if (profileFile == null) { + return null; + } + + // Resolve profile name + String profileName = System.getenv("AWS_PROFILE"); + if (profileName == null || profileName.isEmpty()) { + profileName = "default"; + } + + AwsProfile profile = profileFile.profile(profileName); + return profile != null ? profile.property(key) : null; + } + + /** Resolver returned when IMDS is disabled via configuration. */ + private static final class DisabledResolver implements AwsCredentialsResolver { + private static final IdentityResult DISABLED = IdentityResult.ofError( + ImdsCredentialProvider.class, + "IMDS credential fetching is disabled"); + + @Override + public IdentityResult resolveIdentity(Context requestProperties) { + return DISABLED; + } + } +} diff --git a/aws/aws-credentials-imds/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider b/aws/aws-credentials-imds/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider new file mode 100644 index 000000000..890fe5756 --- /dev/null +++ b/aws/aws-credentials-imds/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider @@ -0,0 +1 @@ +software.amazon.smithy.java.aws.credentials.imds.ImdsCredentialProvider diff --git a/aws/aws-credentials-imds/src/test/java/software/amazon/smithy/java/aws/credentials/imds/ImdsClientTest.java b/aws/aws-credentials-imds/src/test/java/software/amazon/smithy/java/aws/credentials/imds/ImdsClientTest.java new file mode 100644 index 000000000..2cea83c4e --- /dev/null +++ b/aws/aws-credentials-imds/src/test/java/software/amazon/smithy/java/aws/credentials/imds/ImdsClientTest.java @@ -0,0 +1,144 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.imds; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Map; +import java.util.Optional; +import javax.net.ssl.SSLSession; +import org.junit.jupiter.api.Test; + +class ImdsClientTest { + + private static final URI ENDPOINT = URI.create("http://169.254.169.254"); + + @Test + void fetchesCredentialsSuccessfully() throws Exception { + var client = new ImdsClient(ENDPOINT, + mockClient( + response(200, "mock-token"), // PUT token + response(200, "my-role"), // GET discovery + response(200, + "{\"Code\":\"Success\",\"AccessKeyId\":\"AK\",\"SecretAccessKey\":\"SK\",\"Token\":\"T\",\"Expiration\":\"2099-01-01T00:00:00Z\",\"AccountId\":\"123\"}"))); + String json = client.fetchCredentials(null); + assertNotNull(json); + assertTrue(json.contains("AK")); + assertTrue(json.contains("123")); + } + + @Test + void usesProvidedProfileName() throws Exception { + var client = new ImdsClient(ENDPOINT, + mockClient( + response(200, "mock-token"), + response(200, + "{\"Code\":\"Success\",\"AccessKeyId\":\"AK2\",\"SecretAccessKey\":\"SK2\",\"Token\":\"T\",\"Expiration\":\"2099-01-01T00:00:00Z\"}"))); + String json = client.fetchCredentials("custom-role"); + assertNotNull(json); + assertTrue(json.contains("AK2")); + } + + @Test + void fallsBackToLegacyPathOn404() throws Exception { + var client = new ImdsClient(ENDPOINT, + mockClient( + response(200, "mock-token"), + response(404, ""), // extended discovery 404 + response(200, "legacy-role"), // legacy discovery + response(200, + "{\"Code\":\"Success\",\"AccessKeyId\":\"LEG\",\"SecretAccessKey\":\"SK\",\"Token\":\"T\",\"Expiration\":\"2099-01-01T00:00:00Z\"}"))); + String json = client.fetchCredentials(null); + assertNotNull(json); + assertTrue(json.contains("LEG")); + } + + @Test + void throwsWhenTokenFails() { + var client = new ImdsClient(ENDPOINT, + mockClient( + response(500, "error"), + response(500, "error"), + response(500, "error"), + response(500, "error") // all retries fail + )); + assertThrows(IOException.class, () -> client.fetchCredentials(null)); + } + + private static ImdsClient.SimpleHttpClient mockClient(MockResponse... responses) { + Deque queue = new ArrayDeque<>(); + for (MockResponse r : responses) { + queue.add(r); + } + return request -> { + MockResponse r = queue.poll(); + if (r == null) { + return response(404, "").toHttpResponse(request); + } + return r.toHttpResponse(request); + }; + } + + private static MockResponse response(int status, String body) { + return new MockResponse(status, body); + } + + private record MockResponse(int status, String body) { + HttpResponse toHttpResponse(HttpRequest request) { + return new HttpResponse<>() { + @Override + public int statusCode() { + return status; + } + + @Override + public String body() { + return body; + } + + @Override + public HttpRequest request() { + return request; + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return HttpHeaders.of(Map.of(), (a, b) -> true); + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + + @Override + public URI uri() { + return request.uri(); + } + + @Override + public HttpClient.Version version() { + return HttpClient.Version.HTTP_1_1; + } + }; + } + } +} diff --git a/aws/aws-credentials-imds/src/test/java/software/amazon/smithy/java/aws/credentials/imds/ImdsConformanceTest.java b/aws/aws-credentials-imds/src/test/java/software/amazon/smithy/java/aws/credentials/imds/ImdsConformanceTest.java new file mode 100644 index 000000000..a19f5d773 --- /dev/null +++ b/aws/aws-credentials-imds/src/test/java/software/amazon/smithy/java/aws/credentials/imds/ImdsConformanceTest.java @@ -0,0 +1,205 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.imds; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpHeaders; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; +import javax.net.ssl.SSLSession; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.TestFactory; +import software.amazon.smithy.java.core.serde.document.Document; +import software.amazon.smithy.java.json.JsonCodec; +import software.amazon.smithy.model.shapes.ShapeType; + +/** + * Runs the IMDS v2.1 conformance test suite using a mock SimpleHttpClient. + */ +class ImdsConformanceTest { + + private static final JsonCodec CODEC = JsonCodec.builder().build(); + private static final URI ENDPOINT = URI.create("http://169.254.169.254"); + + @TestFactory + Stream imdsTests() throws IOException { + byte[] data; + try (InputStream is = getClass().getResourceAsStream("imds-v21-tests.json")) { + assertNotNull(is); + data = is.readAllBytes(); + } + Document root = CODEC.createDeserializer(data).readDocument(); + + List tests = new ArrayList<>(); + for (Document test : root.asList()) { + String summary = test.getMember("summary").asString(); + tests.add(DynamicTest.dynamicTest(summary, () -> runTest(test))); + } + return tests.stream(); + } + + private void runTest(Document test) throws Exception { + Document config = test.getMember("config"); + String profileName = null; + Document profileNameDoc = config.getMember("ec2InstanceProfileName"); + if (profileNameDoc != null && profileNameDoc.isType(ShapeType.STRING)) { + profileName = profileNameDoc.asString(); + } + + // Check if disabled. + Document envVars = config.getMember("envVars"); + if (envVars != null) { + Document disabled = envVars.getMember("AWS_EC2_METADATA_DISABLED"); + if (disabled != null && "true".equalsIgnoreCase(disabled.asString())) { + for (Document outcome : test.getMember("outcomes").asList()) { + assertEquals("no credentials", outcome.getMember("result").asString()); + } + return; + } + } + + // Build path-aware mock: map path -> queue of responses. + Map> pathResponses = new HashMap<>(); + for (Document exp : test.getMember("expectations").asList()) { + String path = exp.getMember("get").asString(); + Document response = exp.getMember("response"); + int status = response.getMember("status").asInteger(); + Document body = response.getMember("body"); + String bodyStr = ""; + if (body != null) { + bodyStr = body.isType(ShapeType.STRING) ? body.asString() : documentToJson(body); + } + pathResponses.computeIfAbsent(path, k -> new ArrayDeque<>()).add(new MockResp(status, bodyStr)); + } + + // Create mock client that routes by path. + ImdsClient.SimpleHttpClient mockClient = request -> { + if ("PUT".equals(request.method())) { + return fakeResponse(200, "mock-token", request); + } + String reqPath = request.uri().getPath(); + Deque queue = pathResponses.get(reqPath); + if (queue == null || queue.isEmpty()) { + return fakeResponse(404, "", request); + } + MockResp r = queue.poll(); + return fakeResponse(r.status, r.body, request); + }; + + ImdsClient client = new ImdsClient(ENDPOINT, mockClient); + + for (Document outcome : test.getMember("outcomes").asList()) { + String expectedResult = outcome.getMember("result").asString(); + String json; + try { + json = client.fetchCredentials(profileName); + } catch (IOException e) { + if ("no credentials".equals(expectedResult) || "invalid profile".equals(expectedResult)) { + continue; + } + throw e; + } + + if ("no credentials".equals(expectedResult) || "invalid profile".equals(expectedResult)) { + continue; + } + + assertNotNull(json, "Expected credentials but got null"); + if (outcome.getMember("accountId") != null) { + Document creds = CODEC.createDeserializer(json.getBytes(StandardCharsets.UTF_8)).readDocument(); + assertEquals(outcome.getMember("accountId").asString(), + creds.getMember("AccountId").asString()); + } + } + } + + private static String documentToJson(Document doc) { + StringBuilder sb = new StringBuilder("{"); + boolean first = true; + for (String key : doc.getMemberNames()) { + if (!first) { + sb.append(","); + } + first = false; + sb.append("\"").append(key).append("\":"); + Document val = doc.getMember(key); + if (val.isType(ShapeType.STRING) || val.isType(ShapeType.ENUM)) { + sb.append("\"").append(val.asString().replace("\\", "\\\\").replace("\"", "\\\"")).append("\""); + } else if (val.isType(ShapeType.MAP) || val.isType(ShapeType.STRUCTURE)) { + sb.append(documentToJson(val)); + } else { + try { + sb.append(val.asNumber()); + } catch (Exception e) { + sb.append("null"); + } + } + } + sb.append("}"); + return sb.toString(); + } + + private static HttpResponse fakeResponse(int status, String body, HttpRequest request) { + return new HttpResponse<>() { + @Override + public int statusCode() { + return status; + } + + @Override + public String body() { + return body; + } + + @Override + public HttpRequest request() { + return request; + } + + @Override + public Optional> previousResponse() { + return Optional.empty(); + } + + @Override + public HttpHeaders headers() { + return HttpHeaders.of(Map.of(), (a, b) -> true); + } + + @Override + public Optional sslSession() { + return Optional.empty(); + } + + @Override + public URI uri() { + return request.uri(); + } + + @Override + public HttpClient.Version version() { + return HttpClient.Version.HTTP_1_1; + } + }; + } + + private record MockResp(int status, String body) {} +} diff --git a/aws/aws-credentials-imds/src/test/resources/software/amazon/smithy/java/aws/credentials/imds/imds-v21-tests.json b/aws/aws-credentials-imds/src/test/resources/software/amazon/smithy/java/aws/credentials/imds/imds-v21-tests.json new file mode 100644 index 000000000..625022995 --- /dev/null +++ b/aws/aws-credentials-imds/src/test/resources/software/amazon/smithy/java/aws/credentials/imds/imds-v21-tests.json @@ -0,0 +1,653 @@ +[ + { + "summary": "Test IMDS credentials provider with env vars { AWS_EC2_METADATA_DISABLED=true } returns no credentials", + "config": { + "ec2InstanceProfileName": null, + "envVars": { + "AWS_EC2_METADATA_DISABLED": "true" + } + }, + "expectations": [], + "outcomes": [ + { + "result": "no credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider returns valid credentials with account ID", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0001" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0001", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-12T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-12T21:53:17.832308Z", + "UnexpectedElement1": { + "Name": "ignore-me-1" + }, + "AccountId": "123456789101" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0001", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-12T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-12T21:53:17.832308Z", + "UnexpectedElement1": { + "Name": "ignore-me-1" + }, + "AccountId": "123456789101" + } + } + } + ], + "outcomes": [ + { + "result": "credentials", + "accountId": "123456789101" + }, + { + "result": "credentials", + "accountId": "123456789101" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name returns valid credentials with account ID", + "config": { + "ec2InstanceProfileName": "my-profile-0002" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0002", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-13T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-13T21:53:17.832308Z", + "UnexpectedElement2": { + "Name": "ignore-me-2" + }, + "AccountId": "234567891011" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0002", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-13T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-13T21:53:17.832308Z", + "UnexpectedElement2": { + "Name": "ignore-me-2" + }, + "AccountId": "234567891011" + } + } + } + ], + "outcomes": [ + { + "result": "credentials", + "accountId": "234567891011" + }, + { + "result": "credentials", + "accountId": "234567891011" + } + ] + }, + { + "summary": "Test IMDS credentials provider when profile is unstable returns valid credentials with account ID", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0003" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0003", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-14T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-14T21:53:17.832308Z", + "UnexpectedElement3": { + "Name": "ignore-me-3" + }, + "AccountId": "345678910112" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0003", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0003-b" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0003-b", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-14T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-14T21:53:17.832308Z", + "UnexpectedElement3": { + "Name": "ignore-me-3" + }, + "AccountId": "314253647589" + } + } + } + ], + "outcomes": [ + { + "result": "credentials", + "accountId": "345678910112" + }, + { + "result": "credentials", + "accountId": "314253647589" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name when profile is invalid throws an error", + "config": { + "ec2InstanceProfileName": "my-profile-0004" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0004", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0004", + "response": { + "status": 404 + } + } + ], + "outcomes": [ + { + "result": "invalid profile" + } + ] + }, + { + "summary": "Test IMDS credentials provider when account ID is unavailable returns valid credentials", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0005" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0005", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-16T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-16T21:53:17.832308Z", + "UnexpectedElement5": { + "Name": "ignore-me-5" + } + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0005", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-16T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-16T21:53:17.832308Z", + "UnexpectedElement5": { + "Name": "ignore-me-5" + } + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name when account ID is unavailable returns valid credentials", + "config": { + "ec2InstanceProfileName": "my-profile-0006" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0006", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-17T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-17T21:53:17.832308Z", + "UnexpectedElement6": { + "Name": "ignore-me-6" + } + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0006", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-17T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-17T21:53:17.832308Z", + "UnexpectedElement6": { + "Name": "ignore-me-6" + } + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider when account ID is unavailable when profile is unstable returns valid credentials", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0007" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0007", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-18T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-18T21:53:17.832308Z", + "UnexpectedElement7": { + "Name": "ignore-me-7" + } + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0007", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 200, + "body": "my-profile-0007-b" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0007-b", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-18T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-18T21:53:17.832308Z", + "UnexpectedElement7": { + "Name": "ignore-me-7" + } + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name when account ID is unavailable when profile is invalid throws an error", + "config": { + "ec2InstanceProfileName": "my-profile-0008" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0008", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0008", + "response": { + "status": 404 + } + } + ], + "outcomes": [ + { + "result": "invalid profile" + } + ] + }, + { + "summary": "Test IMDS credentials provider against legacy API returns valid credentials", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials", + "response": { + "status": 200, + "body": "my-profile-0009" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0009", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-20T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-20T21:53:17.832308Z" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0009", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-20T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-20T21:53:17.832308Z" + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name against legacy API returns valid credentials", + "config": { + "ec2InstanceProfileName": "my-profile-0010" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0010", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0010", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-21T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-21T21:53:17.832308Z" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0010", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-21T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-21T21:53:17.832308Z" + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider against legacy API when profile is unstable returns valid credentials", + "config": { + "ec2InstanceProfileName": null + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials", + "response": { + "status": 200, + "body": "my-profile-0011" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0011", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-22T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-22T21:53:17.832308Z" + } + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0011", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials", + "response": { + "status": 200, + "body": "my-profile-0011-b" + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0011-b", + "response": { + "status": 200, + "body": { + "Code": "Success", + "LastUpdated": "2025-03-22T20:53:17.832308Z", + "Type": "AWS-HMAC", + "AccessKeyId": "ASIAIOSFODNN7EXAMPLE", + "SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "Token": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKw...(truncated)", + "Expiration": "2025-03-22T21:53:17.832308Z" + } + } + } + ], + "outcomes": [ + { + "result": "credentials" + }, + { + "result": "credentials" + } + ] + }, + { + "summary": "Test IMDS credentials provider with a given profile name against legacy API when profile is invalid throws an error", + "config": { + "ec2InstanceProfileName": "my-profile-0012" + }, + "expectations": [ + { + "get": "/latest/meta-data/iam/security-credentials-extended/my-profile-0012", + "response": { + "status": 404 + } + }, + { + "get": "/latest/meta-data/iam/security-credentials/my-profile-0012", + "response": { + "status": 404 + } + } + ], + "outcomes": [ + { + "result": "invalid profile" + } + ] + } +] diff --git a/settings.gradle.kts b/settings.gradle.kts index e12b1b2ea..9f86b4b53 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -88,6 +88,7 @@ include(":aws:server:aws-server-restjson") include(":aws:aws-auth-api") include(":aws:aws-config") include(":aws:aws-credential-chain") +include(":aws:aws-credentials-imds") // AWS service bundling code include(":aws:aws-service-bundle") From a4e1e28877d0e8334535b5900f5020568e0391ca Mon Sep 17 00:00:00 2001 From: Michael Dowling Date: Sat, 9 May 2026 10:39:02 -0500 Subject: [PATCH 04/13] Allow only relative positioning with builtins Allowing custom provider relative positioning around only builtins simplifies the design, removes the possibility of cycles, and removes the need for a topo sort. Also removing aliases. --- .../aws/config/ProfileCredentialProvider.java | 6 -- .../credentials/chain/AwsCredentialChain.java | 76 +++++++++---------- .../chain/AwsCredentialProvider.java | 8 -- .../credentials/chain/OrderingConstraint.java | 31 +++++--- .../chain/AwsCredentialChainTest.java | 22 +++--- 5 files changed, 68 insertions(+), 75 deletions(-) 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 index ea5b2148d..58f9cea99 100644 --- 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 @@ -5,7 +5,6 @@ package software.amazon.smithy.java.aws.config; -import java.util.List; 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; @@ -24,11 +23,6 @@ public String name() { return "SharedConfig"; } - @Override - public List aliases() { - return List.of("SharedCredentials"); - } - @Override public OrderingConstraint ordering() { return new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG); 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 index bfbf5fb54..1faaecbf7 100644 --- 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 @@ -7,14 +7,16 @@ import java.util.ArrayList; import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedHashMap; +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; @@ -66,19 +68,16 @@ public static AwsCredentialChain create() { } static AwsCredentialChain assemble(List registrations) { - // Index by name and aliases. - Map byName = new HashMap<>(); + // Check for duplicate names. + Set seenNames = new HashSet<>(); for (AwsCredentialProvider r : registrations) { - if (byName.put(r.name(), r) != null) { + if (!seenNames.add(r.name())) { throw new IllegalStateException("Duplicate credential provider registration name: '" + r.name() + "'"); } - for (String alias : r.aliases()) { - byName.put(alias, r); - } } // Separate builtins from relatives. - Map builtins = new LinkedHashMap<>(); + Map builtins = new EnumMap<>(BuiltinProvider.class); List relatives = new ArrayList<>(); for (AwsCredentialProvider r : registrations) { @@ -101,54 +100,56 @@ static AwsCredentialChain assemble(List registrations) { }); ProviderContext ctx = new ProviderContext(executor, Context.create()); - // Build the ordered list: builtin slots first (in enum order), then insert relatives. - List ordered = new ArrayList<>(); - List orderedNames = new ArrayList<>(); + // Precompute insert positions: for each slot, how many claimed slots come before it + // and up to and including it. This avoids re-scanning the enum on every relative insert. + EnumMap insertAfter = new EnumMap<>(BuiltinProvider.class); + EnumMap insertBefore = new EnumMap<>(BuiltinProvider.class); + int count = 0; + for (BuiltinProvider slot : BuiltinProvider.values()) { + insertBefore.put(slot, count); + if (builtins.containsKey(slot)) { + count++; + } + insertAfter.put(slot, count); + } + // Build the ordered list: builtin slots in enum order. + List ordered = new ArrayList<>(); for (BuiltinProvider slot : BuiltinProvider.values()) { AwsCredentialProvider r = builtins.get(slot); if (r != null) { ordered.add(new NamedResolver(r.name(), r.create(ctx))); - orderedNames.add(r.name()); } } - // Insert relative providers. + // Insert relative providers using precomputed positions. for (AwsCredentialProvider r : relatives) { - String target; int insertAt; - if (r.ordering() instanceof OrderingConstraint.After(String provider)) { - target = resolveName(provider, byName); - int idx = orderedNames.indexOf(target); - if (idx < 0) { - throw new IllegalStateException("Credential provider '" + r.name() + "' references unknown " - + "provider '" + provider + "' in its 'after' constraint."); - } - insertAt = idx + 1; - } else if (r.ordering() instanceof OrderingConstraint.Before(String provider)) { - target = resolveName(provider, byName); - int idx = orderedNames.indexOf(target); - if (idx < 0) { - throw new IllegalStateException("Credential provider '" + r.name() + "' references unknown " - + "provider '" + provider + "' in its 'before' constraint."); - } - insertAt = idx; + if (r.ordering() instanceof OrderingConstraint.After(BuiltinProvider slot)) { + insertAt = insertAfter.get(slot); + } else if (r.ordering() instanceof OrderingConstraint.Before(BuiltinProvider slot)) { + insertAt = insertBefore.get(slot); } else { insertAt = ordered.size(); } - + if (insertAt > ordered.size()) { + insertAt = ordered.size(); + } ordered.add(insertAt, new NamedResolver(r.name(), r.create(ctx))); - orderedNames.add(insertAt, r.name()); } - LOGGER.debug("Assembled credential chain: {}", orderedNames); + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Assembled credential chain: {}", + ordered.stream().map(NamedResolver::name).collect(Collectors.joining(", "))); + } + warnDetectedButUnclaimed(builtins); return new AwsCredentialChain(Collections.unmodifiableList(ordered), executor); } private static void warnDetectedButUnclaimed(Map builtins) { for (BuiltinProvider slot : BuiltinProvider.values()) { - if (!builtins.containsKey(slot) && slot.moduleSuggestion() != null && slot.isDetected()) { + if (slot.moduleSuggestion() != null && !builtins.containsKey(slot) && slot.isDetected()) { LOGGER.warn("{} credentials detected but no provider is registered for the '{}' slot. " + "Add '{}' to your dependencies.", slot.name(), @@ -238,10 +239,5 @@ public void close() { executor.shutdownNow(); } - private static String resolveName(String target, Map byName) { - AwsCredentialProvider resolved = byName.get(target); - return resolved != null ? resolved.name() : target; - } - private record NamedResolver(String name, IdentityResolver resolver) {} } diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialProvider.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialProvider.java index d056e7044..b6017148d 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialProvider.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialProvider.java @@ -5,7 +5,6 @@ package software.amazon.smithy.java.aws.credentials.chain; -import java.util.List; import software.amazon.smithy.java.auth.api.identity.IdentityResolver; import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; @@ -18,13 +17,6 @@ public interface AwsCredentialProvider { */ String name(); - /** - * @return alternative names for the provider. - */ - default List aliases() { - return List.of(); - } - /** * @return the ordering constraint for this provider. */ diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/OrderingConstraint.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/OrderingConstraint.java index 9ed564cfe..297d20e2d 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/OrderingConstraint.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/OrderingConstraint.java @@ -7,6 +7,17 @@ /** * Describes where an {@link AwsCredentialProvider} sits in the credential chain. + * + *

Three forms: + *

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

{@link Before} and {@link After} reference {@link BuiltinProvider} enum values only, not + * arbitrary provider names. This eliminates the possibility of cycles in ordering constraints. */ public sealed interface OrderingConstraint { /** @@ -23,27 +34,27 @@ record Builtin(BuiltinProvider slot) implements OrderingConstraint { } /** - * Positions a provider immediately before the named provider. + * Positions a provider immediately before the given builtin slot. * - * @param provider the name of the provider this one must come before. + * @param slot the builtin slot this provider must come before. */ - record Before(String provider) implements OrderingConstraint { + record Before(BuiltinProvider slot) implements OrderingConstraint { public Before { - if (provider == null || provider.isEmpty()) { - throw new IllegalArgumentException("provider must not be null or empty"); + if (slot == null) { + throw new IllegalArgumentException("slot must not be null"); } } } /** - * Positions a provider immediately after the named provider. + * Positions a provider immediately after the given builtin slot. * - * @param provider the name of the provider this one must come after. + * @param slot the builtin slot this provider must come after. */ - record After(String provider) implements OrderingConstraint { + record After(BuiltinProvider slot) implements OrderingConstraint { public After { - if (provider == null || provider.isEmpty()) { - throw new IllegalArgumentException("provider must not be null or empty"); + if (slot == null) { + throw new IllegalArgumentException("slot must not be null"); } } } diff --git a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java index 39c120b63..3a8ab7920 100644 --- a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java +++ b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java @@ -88,7 +88,7 @@ void relativeAfterInsertsCorrectly() { new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), errorResolver("profile")), registration("custom", - new OrderingConstraint.After("env"), + new OrderingConstraint.After(BuiltinProvider.ENVIRONMENT), errorResolver("custom")))); assertEquals(List.of("env", "custom", "profile"), chain.providerNames()); @@ -104,22 +104,22 @@ void relativeBeforeInsertsCorrectly() { new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), errorResolver("profile")), registration("custom", - new OrderingConstraint.Before("profile"), + new OrderingConstraint.Before(BuiltinProvider.SHARED_CONFIG), errorResolver("custom")))); assertEquals(List.of("env", "custom", "profile"), chain.providerNames()); } @Test - void relativeWithUnknownReferenceThrows() { - assertThrows(IllegalStateException.class, - () -> AwsCredentialChain.assemble(List.of( - registration("env", - new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), - errorResolver("env")), - registration("custom", - new OrderingConstraint.After("nonexistent"), - errorResolver("custom"))))); + void relativeToUnclaimedSlotAppendsAtEnd() { + var chain = AwsCredentialChain.assemble(List.of( + registration("env", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("env")), + registration("custom", + new OrderingConstraint.After(BuiltinProvider.EC2_INSTANCE_METADATA), + errorResolver("custom")))); + assertEquals(List.of("env", "custom"), chain.providerNames()); } @Test From 7c23247dda03cef5a03b28f73a47e6b7ed50faf3 Mon Sep 17 00:00:00 2001 From: Michael Dowling Date: Mon, 11 May 2026 13:30:23 -0500 Subject: [PATCH 05/13] Add support for feature IDs to chain --- aws/aws-config/build.gradle.kts | 1 + .../AwsConfigCredentialSourceHandler.java | 12 ++ .../config/AwsProfileCredentialsResolver.java | 7 + .../aws/config/CredentialProcessHandler.java | 11 ++ .../java/aws/config/SessionKeysHandler.java | 9 + .../java/aws/config/StaticKeysHandler.java | 9 + aws/aws-credential-chain/build.gradle.kts | 1 + .../credentials/chain/AwsCredentialChain.java | 17 +- .../chain/AwsCredentialProvider.java | 11 ++ .../chain/CredentialFeatureId.java | 20 +++ .../aws/credentials/chain/FeatureIdTest.java | 159 ++++++++++++++++++ .../imds/ImdsCredentialProvider.java | 9 + .../EnvironmentCredentialProvider.java | 10 ++ .../SystemPropertiesCredentialProvider.java | 10 ++ 14 files changed, 283 insertions(+), 3 deletions(-) create mode 100644 aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialFeatureId.java create mode 100644 aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java diff --git a/aws/aws-config/build.gradle.kts b/aws/aws-config/build.gradle.kts index ea9d061c9..b8b8e258f 100644 --- a/aws/aws-config/build.gradle.kts +++ b/aws/aws-config/build.gradle.kts @@ -13,6 +13,7 @@ dependencies { api(project(":aws:aws-auth-api")) api(project(":auth-api")) implementation(project(":logging")) + implementation(project(":client:client-core")) 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/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 index 98c0099b9..6792bcba0 100644 --- 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 @@ -5,8 +5,10 @@ package software.amazon.smithy.java.aws.config; +import java.util.Set; 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.credentials.chain.CredentialFeatureId; import software.amazon.smithy.java.context.Context; /** @@ -51,4 +53,14 @@ public interface AwsConfigCredentialSourceHandler { * @param requestProperties Context properties associated with the request. */ record ResolutionContext(AwsProfileFile profileFile, String profileName, Context requestProperties) {} + + /** + * The business metric feature ID emitted when this handler successfully resolves credentials. + * Appended to the User-Agent header per the credential chain precedence SEP. + * + * @return the feature ID, or {@code null} if none. + */ + default Set featureIds() { + return Set.of(); + } } 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 index 55e372c86..b0d9c6fba 100644 --- 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 @@ -15,6 +15,7 @@ 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.client.core.CallContext; import software.amazon.smithy.java.context.Context; /** @@ -216,6 +217,12 @@ private IdentityResult tryHandlers( for (AwsConfigCredentialSourceHandler handler : handlers) { IdentityResult attempt = handler.tryResolve(source, ctx); if (attempt != null) { + if (!handler.featureIds().isEmpty()) { + var ids = ctx.requestProperties().get(CallContext.FEATURE_IDS); + if (ids != null) { + ids.addAll(handler.featureIds()); + } + } return attempt; } } 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 index 6516491c2..855e638b8 100644 --- 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 @@ -12,9 +12,11 @@ import java.time.format.DateTimeParseException; import java.util.List; import java.util.Locale; +import java.util.Set; import java.util.concurrent.TimeUnit; import software.amazon.smithy.java.auth.api.identity.IdentityResult; import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; import software.amazon.smithy.java.core.serde.document.Document; import software.amazon.smithy.java.json.JsonCodec; import software.amazon.smithy.java.logging.InternalLogger; @@ -40,6 +42,15 @@ public final class CredentialProcessHandler implements AwsConfigCredentialSource public CredentialProcessHandler() {} + private static final Set FEATURE_IDS = Set.of( + new CredentialFeatureId("v"), + new CredentialFeatureId("w")); + + @Override + public Set featureIds() { + return FEATURE_IDS; + } + @Override public IdentityResult tryResolve( AwsConfigCredentialSource source, 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 index 40f2e20b2..388ad37db 100644 --- 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 @@ -5,8 +5,10 @@ package software.amazon.smithy.java.aws.config; +import java.util.Set; 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.credentials.chain.CredentialFeatureId; /** * Handles {@link AwsConfigCredentialSource.SessionKeys}. @@ -15,6 +17,13 @@ public final class SessionKeysHandler implements AwsConfigCredentialSourceHandle public SessionKeysHandler() {} + private static final Set FEATURE_IDS = Set.of(new CredentialFeatureId("n")); + + @Override + public Set featureIds() { + return FEATURE_IDS; + } + @Override public IdentityResult< AwsCredentialsIdentity> tryResolve(AwsConfigCredentialSource source, ResolutionContext context) { 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 index cb164c475..0cffac39d 100644 --- 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 @@ -5,8 +5,10 @@ package software.amazon.smithy.java.aws.config; +import java.util.Set; 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.credentials.chain.CredentialFeatureId; /** * Handles {@link AwsConfigCredentialSource.StaticKeys}. @@ -15,6 +17,13 @@ public final class StaticKeysHandler implements AwsConfigCredentialSourceHandler public StaticKeysHandler() {} + private static final Set FEATURE_IDS = Set.of(new CredentialFeatureId("n")); + + @Override + public Set featureIds() { + return FEATURE_IDS; + } + @Override public IdentityResult< AwsCredentialsIdentity> tryResolve(AwsConfigCredentialSource source, ResolutionContext context) { diff --git a/aws/aws-credential-chain/build.gradle.kts b/aws/aws-credential-chain/build.gradle.kts index 586f46dff..774920a37 100644 --- a/aws/aws-credential-chain/build.gradle.kts +++ b/aws/aws-credential-chain/build.gradle.kts @@ -10,5 +10,6 @@ extra["moduleName"] = "software.amazon.smithy.java.aws.credentials.chain" dependencies { api(project(":aws:aws-auth-api")) api(project(":auth-api")) + implementation(project(":client:client-core")) 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 index 1faaecbf7..001f75e64 100644 --- 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 @@ -21,6 +21,7 @@ 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.client.core.CallContext; import software.amazon.smithy.java.context.Context; import software.amazon.smithy.java.logging.InternalLogger; @@ -118,7 +119,7 @@ static AwsCredentialChain assemble(List registrations) { for (BuiltinProvider slot : BuiltinProvider.values()) { AwsCredentialProvider r = builtins.get(slot); if (r != null) { - ordered.add(new NamedResolver(r.name(), r.create(ctx))); + ordered.add(new NamedResolver(r.name(), r.featureIds(), r.create(ctx))); } } @@ -135,7 +136,7 @@ static AwsCredentialChain assemble(List registrations) { if (insertAt > ordered.size()) { insertAt = ordered.size(); } - ordered.add(insertAt, new NamedResolver(r.name(), r.create(ctx))); + ordered.add(insertAt, new NamedResolver(r.name(), r.featureIds(), r.create(ctx))); } if (LOGGER.isDebugEnabled()) { @@ -169,9 +170,16 @@ public IdentityResult resolveIdentity(Context requestPro // More cheaply build up a list of failures, and defer string-ing them into a StringBuilder. List errors = new ArrayList<>(); + for (NamedResolver nr : resolvers) { IdentityResult result = nr.resolver.resolveIdentity(requestProperties); if (result.identity() != null) { + if (!nr.featureIds.isEmpty()) { + var ids = requestProperties.get(CallContext.FEATURE_IDS); + if (ids != null) { + ids.addAll(nr.featureIds); + } + } return result; } errors.add(nr.name); @@ -239,5 +247,8 @@ public void close() { executor.shutdownNow(); } - private record NamedResolver(String name, IdentityResolver resolver) {} + private record NamedResolver( + String name, + Set featureIds, + IdentityResolver resolver) {} } diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialProvider.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialProvider.java index b6017148d..c3ffa9457 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialProvider.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialProvider.java @@ -5,6 +5,7 @@ package software.amazon.smithy.java.aws.credentials.chain; +import java.util.Set; import software.amazon.smithy.java.auth.api.identity.IdentityResolver; import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; @@ -31,4 +32,14 @@ public interface AwsCredentialProvider { * @return the resolver. */ IdentityResolver create(ProviderContext context); + + /** + * The business metric feature ID emitted when this provider successfully resolves credentials. + * Appended to the User-Agent header per the credential chain precedence SEP. + * + * @return the feature ID, or {@code null} if none. + */ + default Set featureIds() { + return Set.of(); + } } diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialFeatureId.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialFeatureId.java new file mode 100644 index 000000000..f38f578b6 --- /dev/null +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialFeatureId.java @@ -0,0 +1,20 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.chain; + +import software.amazon.smithy.java.client.core.FeatureId; + +/** + * A {@link FeatureId} for credential resolution business metrics. + * + * @param id the short feature ID string (e.g., "n" for profile static credentials). + */ +public record CredentialFeatureId(String id) implements FeatureId { + @Override + public String getShortName() { + return id; + } +} diff --git a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java new file mode 100644 index 000000000..e9191a366 --- /dev/null +++ b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java @@ -0,0 +1,159 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.chain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; +import software.amazon.smithy.java.auth.api.identity.IdentityResult; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.client.core.CallContext; +import software.amazon.smithy.java.client.core.FeatureId; +import software.amazon.smithy.java.context.Context; + +class FeatureIdTest { + + @Test + void successfulProviderEmitsFeatureId() { + var chain = AwsCredentialChain.assemble(List.of( + provider("env", + BuiltinProvider.ENVIRONMENT, + Set.of(new CredentialFeatureId("g")), + staticResolver("AK", "SK")))); + + Context ctx = Context.create(); + ctx.put(CallContext.FEATURE_IDS, new HashSet<>()); + + chain.resolveIdentity(ctx); + + Set ids = ctx.get(CallContext.FEATURE_IDS); + assertEquals(1, ids.size()); + assertEquals("g", ids.iterator().next().getShortName()); + } + + @Test + void failedProviderDoesNotEmitFeatureId() { + var chain = AwsCredentialChain.assemble(List.of( + provider("env", + BuiltinProvider.ENVIRONMENT, + Set.of(new CredentialFeatureId("g")), + errorResolver("no creds")), + provider("profile", + BuiltinProvider.SHARED_CONFIG, + Set.of(new CredentialFeatureId("n")), + staticResolver("AK", "SK")))); + + Context ctx = Context.create(); + ctx.put(CallContext.FEATURE_IDS, new HashSet<>()); + + chain.resolveIdentity(ctx); + + Set ids = ctx.get(CallContext.FEATURE_IDS); + assertEquals(1, ids.size()); + assertEquals("n", ids.iterator().next().getShortName()); + } + + @Test + void multipleFeatureIdsEmitted() { + var chain = AwsCredentialChain.assemble(List.of( + provider("proc", + BuiltinProvider.SHARED_CONFIG, + Set.of(new CredentialFeatureId("v"), new CredentialFeatureId("w")), + staticResolver("AK", "SK")))); + + Context ctx = Context.create(); + ctx.put(CallContext.FEATURE_IDS, new HashSet<>()); + + chain.resolveIdentity(ctx); + + Set ids = ctx.get(CallContext.FEATURE_IDS); + assertEquals(2, ids.size()); + Set names = new HashSet<>(); + for (FeatureId id : ids) { + names.add(id.getShortName()); + } + assertTrue(names.contains("v")); + assertTrue(names.contains("w")); + } + + @Test + void noFeatureIdsWhenContextKeyNotSet() { + var chain = AwsCredentialChain.assemble(List.of( + provider("env", + BuiltinProvider.ENVIRONMENT, + Set.of(new CredentialFeatureId("g")), + staticResolver("AK", "SK")))); + + // No FEATURE_IDS in context — should not throw. + Context ctx = Context.create(); + var result = chain.resolveIdentity(ctx); + assertEquals("AK", result.identity().accessKeyId()); + } + + private static AwsCredentialProvider provider( + String name, + BuiltinProvider slot, + Set featureIds, + IdentityResolver resolver + ) { + return new AwsCredentialProvider() { + @Override + public String name() { + return name; + } + + @Override + public Set featureIds() { + return featureIds; + } + + @Override + public OrderingConstraint ordering() { + return new OrderingConstraint.Builtin(slot); + } + + @Override + public IdentityResolver create(ProviderContext context) { + return resolver; + } + }; + } + + private static IdentityResolver staticResolver(String ak, String sk) { + IdentityResult result = IdentityResult.of(AwsCredentialsIdentity.create(ak, sk)); + return new IdentityResolver<>() { + @Override + public IdentityResult resolveIdentity(Context ctx) { + return result; + } + + @Override + public Class identityType() { + return AwsCredentialsIdentity.class; + } + }; + } + + private static IdentityResolver errorResolver(String msg) { + IdentityResult result = IdentityResult.ofError(FeatureIdTest.class, msg); + return new IdentityResolver<>() { + @Override + public IdentityResult resolveIdentity(Context ctx) { + return result; + } + + @Override + public Class identityType() { + return AwsCredentialsIdentity.class; + } + }; + } +} diff --git a/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java b/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java index d6b52de98..35cd480a2 100644 --- a/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java +++ b/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java @@ -10,6 +10,7 @@ import java.nio.charset.StandardCharsets; import java.time.Instant; import java.time.format.DateTimeParseException; +import java.util.Set; import software.amazon.smithy.java.auth.api.identity.CachingIdentityResolver; import software.amazon.smithy.java.auth.api.identity.IdentityResolver; import software.amazon.smithy.java.auth.api.identity.IdentityResult; @@ -19,6 +20,7 @@ import software.amazon.smithy.java.aws.config.AwsProfileFile; import software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider; import software.amazon.smithy.java.aws.credentials.chain.BuiltinProvider; +import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; import software.amazon.smithy.java.context.Context; @@ -34,6 +36,8 @@ */ public final class ImdsCredentialProvider implements AwsCredentialProvider { + private static final Set FEATURE_IDS = Set.of(new CredentialFeatureId("0")); + private static final InternalLogger LOGGER = InternalLogger.getLogger(ImdsCredentialProvider.class); private static final URI DEFAULT_ENDPOINT = URI.create("http://169.254.169.254"); private static final JsonCodec CODEC = JsonCodec.builder().build(); @@ -43,6 +47,11 @@ public String name() { return "Ec2InstanceMetadata"; } + @Override + public Set featureIds() { + return FEATURE_IDS; + } + @Override public OrderingConstraint ordering() { return new OrderingConstraint.Builtin(BuiltinProvider.EC2_INSTANCE_METADATA); diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java index 6e7608e8e..eec1b0d30 100644 --- a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java @@ -5,10 +5,12 @@ package software.amazon.smithy.java.aws.client.core.identity; +import java.util.Set; 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.CredentialFeatureId; import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; @@ -17,11 +19,19 @@ * {@link BuiltinProvider#ENVIRONMENT} slot. */ public final class EnvironmentCredentialProvider implements AwsCredentialProvider { + + private static final Set FEATURE_IDS = Set.of(new CredentialFeatureId("g")); + @Override public String name() { return "Environment"; } + @Override + public Set featureIds() { + return FEATURE_IDS; + } + @Override public OrderingConstraint ordering() { return new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT); diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java index 0541c0fc7..eef2bc13e 100644 --- a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java @@ -5,10 +5,12 @@ package software.amazon.smithy.java.aws.client.core.identity; +import java.util.Set; 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.CredentialFeatureId; import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; @@ -17,11 +19,19 @@ * {@link BuiltinProvider#JAVA_SYSTEM_PROPERTIES} slot. */ public final class SystemPropertiesCredentialProvider implements AwsCredentialProvider { + + private static final Set FEATURE_IDS = Set.of(new CredentialFeatureId("f")); + @Override public String name() { return "JavaSystemProperties"; } + @Override + public Set featureIds() { + return FEATURE_IDS; + } + @Override public OrderingConstraint ordering() { return new OrderingConstraint.Builtin(BuiltinProvider.JAVA_SYSTEM_PROPERTIES); From bf154dea018bcf63965396ca12a7f17ecd185dbe Mon Sep 17 00:00:00 2001 From: Michael Dowling Date: Mon, 11 May 2026 13:58:44 -0500 Subject: [PATCH 06/13] Invalidate identity resolver on stale error --- .../client/core/AwsCredentialChainPlugin.java | 4 +- .../InvalidateOnAuthFailureInterceptor.java | 47 ++++++++++ ...nvalidateOnAuthFailureInterceptorTest.java | 93 +++++++++++++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/InvalidateOnAuthFailureInterceptor.java create mode 100644 aws/client/aws-client-core/src/test/java/software/amazon/smithy/java/aws/client/core/InvalidateOnAuthFailureInterceptorTest.java diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/AwsCredentialChainPlugin.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/AwsCredentialChainPlugin.java index 128dd76e7..e591ce64b 100644 --- a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/AwsCredentialChainPlugin.java +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/AwsCredentialChainPlugin.java @@ -38,7 +38,9 @@ public Phase getPluginPhase() { @Override public void configureClient(ClientConfig.Builder config) { if (needsAwsCredentials(config) && !hasAwsCredentialsResolver(config)) { - config.addIdentityResolver(AwsCredentialChain.create()); + var chain = AwsCredentialChain.create(); + config.addIdentityResolver(chain); + config.addInterceptor(new InvalidateOnAuthFailureInterceptor(chain)); } } diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/InvalidateOnAuthFailureInterceptor.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/InvalidateOnAuthFailureInterceptor.java new file mode 100644 index 000000000..5c794e15e --- /dev/null +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/InvalidateOnAuthFailureInterceptor.java @@ -0,0 +1,47 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.client.core; + +import java.util.Set; +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; +import software.amazon.smithy.java.client.core.interceptors.ClientInterceptor; +import software.amazon.smithy.java.client.core.interceptors.OutputHook; +import software.amazon.smithy.java.core.error.ModeledException; + +/** + * Interceptor that invalidates cached credentials when a service returns an authentication error + * (HTTP 401 or 403), forcing the next retry attempt to resolve fresh credentials. + */ +final class InvalidateOnAuthFailureInterceptor implements ClientInterceptor { + + private final IdentityResolver resolver; + + // well-known error names. Ideally the wire would have signal so we don't need this. + private static final Set EXPIRED_NAMES = Set.of( + "ExpiredToken", + "InvalidToken", + "AuthFailure"); + + InvalidateOnAuthFailureInterceptor(IdentityResolver resolver) { + this.resolver = resolver; + } + + @Override + public void readAfterAttempt(OutputHook hook, RuntimeException error) { + if (isAuthError(error)) { + resolver.invalidate(); + } + } + + private static boolean isAuthError(RuntimeException error) { + // Check for common auth failure error names. + if (error instanceof ModeledException me) { + var name = me.schema().id().getName(); + return EXPIRED_NAMES.contains(name); + } + return false; + } +} diff --git a/aws/client/aws-client-core/src/test/java/software/amazon/smithy/java/aws/client/core/InvalidateOnAuthFailureInterceptorTest.java b/aws/client/aws-client-core/src/test/java/software/amazon/smithy/java/aws/client/core/InvalidateOnAuthFailureInterceptorTest.java new file mode 100644 index 000000000..a35d92c16 --- /dev/null +++ b/aws/client/aws-client-core/src/test/java/software/amazon/smithy/java/aws/client/core/InvalidateOnAuthFailureInterceptorTest.java @@ -0,0 +1,93 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.client.core; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; +import software.amazon.smithy.java.auth.api.identity.IdentityResult; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.context.Context; +import software.amazon.smithy.java.core.error.ModeledException; +import software.amazon.smithy.java.core.schema.Schema; +import software.amazon.smithy.java.core.serde.ShapeSerializer; +import software.amazon.smithy.model.shapes.ShapeId; + +class InvalidateOnAuthFailureInterceptorTest { + + @Test + void invalidatesOnExpiredToken() { + var counter = new CountingResolver(); + var interceptor = new InvalidateOnAuthFailureInterceptor(counter); + + interceptor.readAfterAttempt(null, authError("ExpiredToken")); + assertEquals(1, counter.invalidateCount.get()); + } + + @Test + void invalidatesOnAuthFailure() { + var counter = new CountingResolver(); + var interceptor = new InvalidateOnAuthFailureInterceptor(counter); + + interceptor.readAfterAttempt(null, authError("AuthFailure")); + assertEquals(1, counter.invalidateCount.get()); + } + + @Test + void doesNotInvalidateOnNonAuthError() { + var counter = new CountingResolver(); + var interceptor = new InvalidateOnAuthFailureInterceptor(counter); + + interceptor.readAfterAttempt(null, new RuntimeException("network error")); + assertEquals(0, counter.invalidateCount.get()); + } + + @Test + void doesNotInvalidateOnNull() { + var counter = new CountingResolver(); + var interceptor = new InvalidateOnAuthFailureInterceptor(counter); + + interceptor.readAfterAttempt(null, null); + assertEquals(0, counter.invalidateCount.get()); + } + + private static RuntimeException authError(String errorName) { + Schema schema = Schema.createString(ShapeId.from("com.example#" + errorName)); + return new ModeledException(schema, errorName + " error") { + @Override + public void serialize(ShapeSerializer serializer) {} + + @Override + public void serializeMembers(ShapeSerializer serializer) {} + + @Override + public T getMemberValue(Schema member) { + return null; + } + }; + } + + private static class CountingResolver implements IdentityResolver { + final AtomicInteger invalidateCount = new AtomicInteger(0); + + @Override + public IdentityResult resolveIdentity(Context ctx) { + return IdentityResult.of(AwsCredentialsIdentity.create("AK", "SK")); + } + + @Override + public Class identityType() { + return AwsCredentialsIdentity.class; + } + + @Override + public void invalidate() { + invalidateCount.incrementAndGet(); + } + } +} From 9a4acf8c529aabc2ae4cf2a21586d2413cef70a8 Mon Sep 17 00:00:00 2001 From: Michael Dowling Date: Mon, 11 May 2026 15:38:24 -0500 Subject: [PATCH 07/13] Add credential chain codegen plugin Automatically add credential chain runtime plugin if AWS auth is used. --- .../aws/AwsCredentialChainIntegration.java | 38 +++++++++++++++++++ ...smithy.java.codegen.JavaCodegenIntegration | 1 + 2 files changed, 39 insertions(+) create mode 100644 codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/client/integrations/aws/AwsCredentialChainIntegration.java diff --git a/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/client/integrations/aws/AwsCredentialChainIntegration.java b/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/client/integrations/aws/AwsCredentialChainIntegration.java new file mode 100644 index 000000000..f11e40002 --- /dev/null +++ b/codegen/codegen-plugin/src/main/java/software/amazon/smithy/java/codegen/client/integrations/aws/AwsCredentialChainIntegration.java @@ -0,0 +1,38 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.codegen.client.integrations.aws; + +import software.amazon.smithy.java.codegen.CodeGenerationContext; +import software.amazon.smithy.java.codegen.JavaCodegenIntegration; +import software.amazon.smithy.java.codegen.JavaCodegenSettings; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.knowledge.ServiceIndex; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.utils.SmithyInternalApi; + +/** + * Codegen integration that adds {@code AwsCredentialChainPlugin} as a default plugin for any + * service that uses an AWS auth scheme (sigv4 or sigv4a). + */ +@SmithyInternalApi +public final class AwsCredentialChainIntegration implements JavaCodegenIntegration { + + private static final String PLUGIN_CLASS = "software.amazon.smithy.java.aws.client.core.AwsCredentialChainPlugin"; + private static final ShapeId SIGV4 = ShapeId.from("aws.auth#sigv4"); + private static final ShapeId SIGV4A = ShapeId.from("aws.auth#sigv4a"); + + @Override + public void customize(CodeGenerationContext context) { + Model model = context.model(); + JavaCodegenSettings settings = context.settings(); + var service = model.expectShape(settings.service(), ServiceShape.class); + var authSchemes = ServiceIndex.of(model).getEffectiveAuthSchemes(service); + if (authSchemes.containsKey(SIGV4) || authSchemes.containsKey(SIGV4A)) { + settings.addDefaultPlugin(PLUGIN_CLASS); + } + } +} diff --git a/codegen/codegen-plugin/src/main/resources/META-INF/services/software.amazon.smithy.java.codegen.JavaCodegenIntegration b/codegen/codegen-plugin/src/main/resources/META-INF/services/software.amazon.smithy.java.codegen.JavaCodegenIntegration index c820c760d..7f77ed5dd 100644 --- a/codegen/codegen-plugin/src/main/resources/META-INF/services/software.amazon.smithy.java.codegen.JavaCodegenIntegration +++ b/codegen/codegen-plugin/src/main/resources/META-INF/services/software.amazon.smithy.java.codegen.JavaCodegenIntegration @@ -1 +1,2 @@ software.amazon.smithy.java.codegen.client.integrations.javadoc.ClientJavadocExamplesIntegration +software.amazon.smithy.java.codegen.client.integrations.aws.AwsCredentialChainIntegration From 3fae9c47ddf28f78bd828cd42781945ce586041d Mon Sep 17 00:00:00 2001 From: Michael Dowling Date: Mon, 11 May 2026 15:59:10 -0500 Subject: [PATCH 08/13] Add READMEs for new credential packages --- aws/aws-config/README.md | 56 ++++++++++++++++++ aws/aws-credential-chain/README.md | 93 ++++++++++++++++++++++++++++++ aws/aws-credentials-imds/README.md | 43 ++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 aws/aws-config/README.md create mode 100644 aws/aws-credential-chain/README.md create mode 100644 aws/aws-credentials-imds/README.md diff --git a/aws/aws-config/README.md b/aws/aws-config/README.md new file mode 100644 index 000000000..052334b6f --- /dev/null +++ b/aws/aws-config/README.md @@ -0,0 +1,56 @@ +# AWS Config + +Parses AWS shared configuration files (`~/.aws/config` and `~/.aws/credentials`) and resolves credentials from profiles. + +## Dependency + +```kotlin +dependencies { + implementation("software.amazon.smithy.java:aws-config:1.1.0") +} +``` + +## Usage + +Config file-based credential resolution is wired up automatically when +`AwsCredentialChainPlugin` is installed on a client. This module registers +itself in the `SHARED_CONFIG` chain slot via ServiceLoader, so no additional +code is needed beyond adding the dependency. + +## Programmatic config file support + +You can load and query config files directly: + +```java +// Load and query the config file +AwsProfileFile file = AwsProfileFile.load(); +AwsProfile profile = file.profile("default"); +String region = profile.property("region"); + +// Resolve credentials from a profile directly +AwsProfileCredentialsResolver resolver = AwsProfileCredentialsResolver.builder() + .profileName("dev") + .build(); + +IdentityResult result = resolver.resolveIdentity(Context.empty()); +``` + +## Supported credential sources + +Profiles can define credentials in multiple ways. This module handles: + +- **Static keys** — `aws_access_key_id` + `aws_secret_access_key` +- **Session keys** — static keys + `aws_session_token` +- **credential_process** — external program that outputs JSON credentials + +## Modular credential sources + +Additional sources like IMDS, AssumeRole, SSO, WebIdentity, and Login are +detected and typed but require separate handler modules (`aws-credentials-sts`, +`aws-credentials-sso`, etc.) to resolve. When possible, this module detects +if you intended to use one of these providers but are missing the dependency. + +## Extensibility + +Implement `AwsConfigCredentialSourceHandler` and register via +`META-INF/services` to handle additional source types. diff --git a/aws/aws-credential-chain/README.md b/aws/aws-credential-chain/README.md new file mode 100644 index 000000000..d09f53ee0 --- /dev/null +++ b/aws/aws-credential-chain/README.md @@ -0,0 +1,93 @@ +# AWS Credential Chain + +Assembles an ordered AWS credential provider chain from SPI-discovered +providers. Provides the infrastructure for modular credential resolution. + +## Dependency + +```kotlin +dependencies { + implementation("software.amazon.smithy.java:aws-credential-chain:1.1.0") +} +``` + +## Wiring to a client + +### Automatic (codegen) + +Services modeled with `@aws.auth#sigv4` or `@aws.auth#sigv4a` automatically get +`AwsCredentialChainPlugin` added as a default plugin during code generation. +No manual wiring is needed: just add the provider modules you need to your +runtime dependencies. + +### Manual + +```java +var client = MyClient.builder() + .addPlugin(new AwsCredentialChainPlugin()) + .build(); +``` + +The plugin registers the credential chain as the client's identity resolver and +adds an interceptor that invalidates cached credentials on auth failures +(`ExpiredToken`, `InvalidToken`, `AuthFailure`). + +## Standalone usage + +```java +try (AwsCredentialChain chain = AwsCredentialChain.create()) { + IdentityResult result = chain.resolveIdentity(context); +} +``` + +## How it works + +The chain discovers `AwsCredentialProvider` implementations via ServiceLoader, +orders them by builtin enum slots, and tries each in order until one succeeds. + +## Builtin slots (in priority order) + +1. `CODE` — programmatic +2. `JAVA_SYSTEM_PROPERTIES` — `aws.accessKeyId` +3. `ENVIRONMENT` — `AWS_ACCESS_KEY_ID` +4. `WEB_IDENTITY_TOKEN_ENV` — `AWS_WEB_IDENTITY_TOKEN_FILE` + `AWS_ROLE_ARN` +5. `SHARED_CONFIG` — `~/.aws/config` / `~/.aws/credentials` +6. `ECS_CONTAINER` — `AWS_CONTAINER_CREDENTIALS_FULL_URI` +7. `EC2_INSTANCE_METADATA` — IMDS + +## Ordering + +Providers position themselves relative to builtin slots using +`OrderingConstraint`: + +- `Builtin(slot)` — claims a builtin slot (one provider per slot). Only one + provider can claim a builtin. Not all builtins have to be claimed. +- `Before(slot)` — inserts before the given slot's position +- `After(slot)` — inserts after the given slot's position + +Before/After reference enum values only, so cycles are impossible. If the +referenced slot has no registered provider, the custom provider is placed +where that slot would be in enum order. + +## Adding a custom provider + +```java +public class MyProvider implements AwsCredentialProvider { + @Override + public String name() { + return "MyCustomProvider"; + } + + @Override + public OrderingConstraint ordering() { + return new OrderingConstraint.After(BuiltinProvider.SHARED_CONFIG); + } + + @Override + public IdentityResolver create(ProviderContext ctx) { + return new MyResolver(); + } +} +``` + +Register in `META-INF/services/software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider`. diff --git a/aws/aws-credentials-imds/README.md b/aws/aws-credentials-imds/README.md new file mode 100644 index 000000000..f4e764314 --- /dev/null +++ b/aws/aws-credentials-imds/README.md @@ -0,0 +1,43 @@ +# AWS Credentials IMDS + +Credential provider for EC2 instances using the Instance Metadata Service +(IMDSv2). + +## Dependency + +```kotlin +dependencies { + implementation("software.amazon.smithy.java:aws-credentials-imds:1.1.0") +} +``` + +## Usage + +No code changes are needed. The provider is discovered automatically via +ServiceLoader and claims the `EC2_INSTANCE_METADATA` slot in the credential +chain. + +## Behavior + +- Uses IMDSv2 exclusively (no v1 fallback) +- Tries the extended API first (`/security-credentials-extended/`), falls back + to legacy on 404 +- Caches credentials with background refresh before expiration +- Implements static stability: expired credentials are returned during outages +- Retries with exponential backoff (3 attempts) + +## Configuration + +Configuration is checked in priority order. The first non-null value wins. + + system property > env var > config file + +| Source | Key | Effect | +|--------|-----|--------| +| System property | `aws.disableEc2Metadata=true` | Disables IMDS | +| Environment | `AWS_EC2_METADATA_DISABLED=true` | Disables IMDS | +| Config file | `disable_ec2_metadata=true` | Disables IMDS | +| Environment | `AWS_EC2_METADATA_SERVICE_ENDPOINT` | Overrides endpoint (e.g., for IPv6) | +| System property | `aws.ec2InstanceProfileName` | Skips profile discovery | +| Environment | `AWS_EC2_INSTANCE_PROFILE_NAME` | Skips profile discovery | +| Config file | `ec2_instance_profile_name` | Skips profile discovery | From 96d40cc8a8a5fd022dfe3be5af75f7453216c550 Mon Sep 17 00:00:00 2001 From: Michael Dowling Date: Tue, 12 May 2026 14:45:20 -0500 Subject: [PATCH 09/13] Make chain and handlers generic over identity type --- .../api/identity/AwsCredentialsResolver.java | 2 + aws/aws-config/README.md | 2 +- .../AwsConfigCredentialSourceHandler.java | 53 +++-- .../aws/config/CredentialProcessHandler.java | 6 +- .../aws/config/ProfileCredentialProvider.java | 20 +- ...lver.java => ProfileIdentityResolver.java} | 93 +++++---- .../java/aws/config/SessionKeysHandler.java | 6 +- .../java/aws/config/StaticKeysHandler.java | 6 +- ...s.credentials.chain.ChainIdentityProvider} | 0 .../AwsProfileCredentialsResolverTest.java | 50 +++-- .../config/CredentialProcessHandlerTest.java | 2 +- aws/aws-credential-chain/README.md | 36 ++-- .../chain/AwsCredentialProvider.java | 45 ---- .../chain/ChainIdentityProvider.java | 50 +++++ ...dentialChain.java => CredentialChain.java} | 118 +++++++---- .../credentials/chain/OrderingConstraint.java | 2 +- .../credentials/chain/ProviderContext.java | 9 +- .../chain/AwsCredentialChainTest.java | 193 +++++++++++------- .../aws/credentials/chain/FeatureIdTest.java | 66 +++--- .../imds/ImdsCredentialProvider.java | 18 +- ...s.credentials.chain.ChainIdentityProvider} | 0 .../client/core/AwsCredentialChainPlugin.java | 4 +- .../EnvironmentCredentialProvider.java | 17 +- .../SystemPropertiesCredentialProvider.java | 17 +- ...s.credentials.chain.ChainIdentityProvider} | 0 25 files changed, 474 insertions(+), 341 deletions(-) rename aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/{AwsProfileCredentialsResolver.java => ProfileIdentityResolver.java} (78%) rename aws/aws-config/src/main/resources/META-INF/services/{software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider => software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider} (100%) delete mode 100644 aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialProvider.java create mode 100644 aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainIdentityProvider.java rename aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/{AwsCredentialChain.java => CredentialChain.java} (70%) rename aws/aws-credentials-imds/src/main/resources/META-INF/services/{software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider => software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider} (100%) rename aws/client/aws-client-core/src/main/resources/META-INF/services/{software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider => software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider} (100%) diff --git a/aws/aws-auth-api/src/main/java/software/amazon/smithy/java/aws/auth/api/identity/AwsCredentialsResolver.java b/aws/aws-auth-api/src/main/java/software/amazon/smithy/java/aws/auth/api/identity/AwsCredentialsResolver.java index 6fd2aa616..21fae6d20 100644 --- a/aws/aws-auth-api/src/main/java/software/amazon/smithy/java/aws/auth/api/identity/AwsCredentialsResolver.java +++ b/aws/aws-auth-api/src/main/java/software/amazon/smithy/java/aws/auth/api/identity/AwsCredentialsResolver.java @@ -9,6 +9,8 @@ /** * An {@link IdentityResolver} that resolves a {@link AwsCredentialsIdentity} for authentication. + * + *

Note: this is a convenience only. Do not rely on this for limiting subtypes. */ public interface AwsCredentialsResolver extends IdentityResolver { @Override diff --git a/aws/aws-config/README.md b/aws/aws-config/README.md index 052334b6f..00e1c3383 100644 --- a/aws/aws-config/README.md +++ b/aws/aws-config/README.md @@ -28,7 +28,7 @@ AwsProfile profile = file.profile("default"); String region = profile.property("region"); // Resolve credentials from a profile directly -AwsProfileCredentialsResolver resolver = AwsProfileCredentialsResolver.builder() +var resolver = ProfileIdentityResolver.builder(AwsCredentialsIdentity.class) .profileName("dev") .build(); 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 index 6792bcba0..9282738f5 100644 --- 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 @@ -6,32 +6,36 @@ package software.amazon.smithy.java.aws.config; import java.util.Set; +import software.amazon.smithy.java.auth.api.identity.Identity; import software.amazon.smithy.java.auth.api.identity.IdentityResult; -import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; import software.amazon.smithy.java.context.Context; /** - * Strategy for turning an {@link AwsConfigCredentialSource} into an {@link AwsCredentialsIdentity}. + * Strategy for turning an {@link AwsConfigCredentialSource} into an {@link Identity}. * *

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 parameterized by identity type. For example, an SSO handler that exchanges a token for + * AWS credentials implements {@code AwsConfigCredentialSourceHandler}, while one + * that returns the token directly for bearer auth implements {@code AwsConfigCredentialSourceHandler}. + * *

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. + * filters handlers by {@link #identityType()} and, for each source, tries matching handlers until one returns non-null. * - *

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). + * @param the identity type this handler produces. */ -public interface AwsConfigCredentialSourceHandler { +public interface AwsConfigCredentialSourceHandler { + /** + * The identity type this handler resolves. + * + * @return the identity class (e.g., {@code AwsCredentialsIdentity.class} or {@code TokenIdentity.class}). + */ + Class identityType(); + /** * Attempt to resolve an identity from a credential source. * @@ -39,28 +43,23 @@ public interface AwsConfigCredentialSourceHandler { * @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); + IdentityResult tryResolve(AwsConfigCredentialSource source, ResolutionContext context); /** - * Information passed from the enclosing resolver to each handler invocation. + * The business metric feature IDs emitted when this handler successfully resolves an identity. * - *

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). + * @return the feature IDs, or empty if none. + */ + default Set featureIds() { + return Set.of(); + } + + /** + * Information passed from the enclosing resolver to each handler invocation. * * @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) {} - - /** - * The business metric feature ID emitted when this handler successfully resolves credentials. - * Appended to the User-Agent header per the credential chain precedence SEP. - * - * @return the feature ID, or {@code null} if none. - */ - default Set featureIds() { - return Set.of(); - } } 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 index 855e638b8..2c1ac7a61 100644 --- 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 @@ -33,7 +33,7 @@ *

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 { +public final class CredentialProcessHandler implements AwsConfigCredentialSourceHandler { private static final InternalLogger LOGGER = InternalLogger.getLogger(CredentialProcessHandler.class); private static final JsonCodec CODEC = JsonCodec.builder().build(); @@ -47,6 +47,10 @@ public CredentialProcessHandler() {} new CredentialFeatureId("w")); @Override + public Class identityType() { + return AwsCredentialsIdentity.class; + } + public Set featureIds() { return FEATURE_IDS; } 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 index 58f9cea99..4ad90cb82 100644 --- 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 @@ -6,18 +6,19 @@ package software.amazon.smithy.java.aws.config; import software.amazon.smithy.java.auth.api.identity.CachingIdentityResolver; +import software.amazon.smithy.java.auth.api.identity.Identity; import software.amazon.smithy.java.auth.api.identity.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.ChainIdentityProvider; 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 + * Registers {@link ProfileIdentityResolver} in the credential chain's * {@link BuiltinProvider#SHARED_CONFIG} slot. */ -public final class ProfileCredentialProvider implements AwsCredentialProvider { +public final class ProfileCredentialProvider implements ChainIdentityProvider { @Override public String name() { return "SharedConfig"; @@ -29,12 +30,13 @@ public OrderingConstraint ordering() { } @Override - public IdentityResolver create(ProviderContext context) { - AwsProfileCredentialsResolver resolver = AwsProfileCredentialsResolver.builder().build(); - // Share the loaded profile file with other providers via context. + @SuppressWarnings("unchecked") + public IdentityResolver create(Class identityType, ProviderContext context) { + if (identityType != AwsCredentialsIdentity.class) { + return null; + } + var resolver = ProfileIdentityResolver.builder(AwsCredentialsIdentity.class).build(); context.properties().put(AwsProfileFile.CONTEXT_KEY, resolver.profileFile()); - return CachingIdentityResolver.builder(resolver) - .executor(context.executor()) - .build(); + return (IdentityResolver) CachingIdentityResolver.builder(resolver).executor(context.executor()).build(); } } 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/ProfileIdentityResolver.java similarity index 78% rename from aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsProfileCredentialsResolver.java rename to aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/ProfileIdentityResolver.java index b0d9c6fba..55577ae6e 100644 --- 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/ProfileIdentityResolver.java @@ -11,22 +11,20 @@ import java.util.List; import java.util.Objects; import java.util.ServiceLoader; +import software.amazon.smithy.java.auth.api.identity.Identity; +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; import software.amazon.smithy.java.auth.api.identity.IdentityResult; -import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; -import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsResolver; import software.amazon.smithy.java.aws.config.AwsConfigCredentialSourceHandler.ResolutionContext; import software.amazon.smithy.java.client.core.CallContext; 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. + * An {@link IdentityResolver} 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: + *

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 @@ -63,7 +61,7 @@ * {@link AwsProfileFile#refresh()}). Concurrent callers of {@link #resolveIdentity(Context)} * observe the new state atomically after refresh completes. */ -public final class AwsProfileCredentialsResolver implements AwsCredentialsResolver { +public final class ProfileIdentityResolver implements IdentityResolver { /** Environment variable used to select the default profile name. */ public static final String AWS_PROFILE_ENV = "AWS_PROFILE"; @@ -74,16 +72,18 @@ public final class AwsProfileCredentialsResolver implements AwsCredentialsResolv /** Profile name used when nothing else is configured. */ public static final String DEFAULT_PROFILE_NAME = "default"; + private final Class identityType; private final String profileName; - private final List handlers; + private final List> handlers; private final boolean ignoreUnhandledSources; private final AwsProfileFile profileFile; private final String sourceDescription; - private final IdentityResult profileNotFoundError; + private final IdentityResult profileNotFoundError; - private AwsProfileCredentialsResolver(Builder b) { + private ProfileIdentityResolver(Builder b) { + this.identityType = b.identityType; this.profileName = b.profileName != null ? b.profileName : resolveDefaultProfileName(); - this.handlers = b.handlers.isEmpty() ? discoverHandlers() : List.copyOf(b.handlers); + this.handlers = b.handlers.isEmpty() ? discoverHandlers(b.identityType) : List.copyOf(b.handlers); this.ignoreUnhandledSources = b.ignoreUnhandledSources; if (b.profileFile != null) { @@ -106,10 +106,16 @@ private AwsProfileCredentialsResolver(Builder b) { "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); + private static List> discoverHandlers( + Class identityType + ) { + List> found = new ArrayList<>(); + for (AwsConfigCredentialSourceHandler h : ServiceLoader.load(AwsConfigCredentialSourceHandler.class)) { + if (h.identityType() == identityType) { + @SuppressWarnings("unchecked") + var typed = (AwsConfigCredentialSourceHandler) h; + found.add(typed); + } } return Collections.unmodifiableList(found); } @@ -137,8 +143,8 @@ private static String describeSource(AwsProfileFile file) { /** * @return a new builder. */ - public static Builder builder() { - return new Builder(); + public static Builder builder(Class identityType) { + return new Builder<>(identityType); } /** @@ -159,7 +165,7 @@ public AwsProfileFile profileFile() { /** * @return an unmodifiable, ordered view of this resolver's registered handlers. */ - public List handlers() { + public List> handlers() { return handlers; } @@ -177,7 +183,12 @@ public void invalidate() { } @Override - public IdentityResult resolveIdentity(Context requestProperties) { + public Class identityType() { + return identityType; + } + + @Override + public IdentityResult resolveIdentity(Context requestProperties) { // Access each time since it can be refreshed. AwsProfile profile = profileFile.profile(profileName); if (profile == null) { @@ -194,7 +205,7 @@ public IdentityResult resolveIdentity(Context requestPro ResolutionContext ctx = new ResolutionContext(profileFile, profileName, requestProperties); for (AwsConfigCredentialSource source : sources) { - IdentityResult result = tryHandlers(source, ctx); + IdentityResult result = tryHandlers(source, ctx); if (result != null) { return result; } else if (!ignoreUnhandledSources) { @@ -202,7 +213,7 @@ public IdentityResult resolveIdentity(Context requestPro } } - String typeName = sources.get(0).getClass().getSimpleName(); + String typeName = sources.getFirst().getClass().getSimpleName(); return IdentityResult.ofError( getClass(), "AWS profile '" + profileName + "' requires a credential source of type '" + typeName + "', " @@ -210,12 +221,12 @@ public IdentityResult resolveIdentity(Context requestPro + "(for example, an STS or SSO-backed handler from another module)."); } - private IdentityResult tryHandlers( + private IdentityResult tryHandlers( AwsConfigCredentialSource source, ResolutionContext ctx ) { - for (AwsConfigCredentialSourceHandler handler : handlers) { - IdentityResult attempt = handler.tryResolve(source, ctx); + for (var handler : handlers) { + IdentityResult attempt = handler.tryResolve(source, ctx); if (attempt != null) { if (!handler.featureIds().isEmpty()) { var ids = ctx.requestProperties().get(CallContext.FEATURE_IDS); @@ -243,35 +254,35 @@ private static String resolveDefaultProfileName() { return DEFAULT_PROFILE_NAME; } - /** - * Builder for {@link AwsProfileCredentialsResolver}. - */ - public static final class Builder { + public static final class Builder { + private final Class identityType; 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 final List> handlers = new ArrayList<>(); private boolean ignoreUnhandledSources; - private Builder() {} + private Builder(Class identityType) { + this.identityType = identityType; + } /** * 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) { + public Builder profileName(String profileName) { this.profileName = profileName; return this; } /** - * Use a pre-loaded {@link AwsProfileFile}. Mutually exclusive with {@link #configFile(Path)} + * Use a preloaded {@link AwsProfileFile}. Mutually exclusive with {@link #configFile(Path)} * and {@link #credentialsFile(Path)}. */ - public Builder profileFile(AwsProfileFile profileFile) { + public Builder profileFile(AwsProfileFile profileFile) { this.profileFile = Objects.requireNonNull(profileFile, "profileFile"); this.configFile = null; this.configFileSet = false; @@ -284,7 +295,7 @@ public Builder profileFile(AwsProfileFile profileFile) { * 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) { + public Builder configFile(Path configFile) { this.profileFile = null; this.configFile = configFile; this.configFileSet = true; @@ -295,7 +306,7 @@ public Builder configFile(Path configFile) { * 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) { + public Builder credentialsFile(Path credentialsFile) { this.profileFile = null; this.credentialsFile = credentialsFile; this.credentialsFileSet = true; @@ -307,10 +318,10 @@ public Builder credentialsFile(Path credentialsFile) { * 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 + * handlers via {@link ServiceLoader}. Calling this method replaces ServiceLoader discovery * entirely; only explicitly added handlers will be used. */ - public Builder addHandler(AwsConfigCredentialSourceHandler handler) { + public Builder addHandler(AwsConfigCredentialSourceHandler handler) { this.handlers.add(Objects.requireNonNull(handler, "handler")); return this; } @@ -321,7 +332,7 @@ public Builder addHandler(AwsConfigCredentialSourceHandler handler) { * matching the AWS SDK shared-configuration specification's requirement that the highest-priority source * MUST be used. */ - public Builder ignoreUnhandledSources(boolean ignoreUnhandledSources) { + public Builder ignoreUnhandledSources(boolean ignoreUnhandledSources) { this.ignoreUnhandledSources = ignoreUnhandledSources; return this; } @@ -329,8 +340,8 @@ public Builder ignoreUnhandledSources(boolean ignoreUnhandledSources) { /** * Build the resolver. */ - public AwsProfileCredentialsResolver build() { - return new AwsProfileCredentialsResolver(this); + public ProfileIdentityResolver build() { + return new ProfileIdentityResolver<>(this); } } } 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 index 388ad37db..7b1abd461 100644 --- 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 @@ -13,13 +13,17 @@ /** * Handles {@link AwsConfigCredentialSource.SessionKeys}. */ -public final class SessionKeysHandler implements AwsConfigCredentialSourceHandler { +public final class SessionKeysHandler implements AwsConfigCredentialSourceHandler { public SessionKeysHandler() {} private static final Set FEATURE_IDS = Set.of(new CredentialFeatureId("n")); @Override + public Class identityType() { + return AwsCredentialsIdentity.class; + } + public Set featureIds() { return FEATURE_IDS; } 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 index 0cffac39d..3e9f4503c 100644 --- 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 @@ -13,13 +13,17 @@ /** * Handles {@link AwsConfigCredentialSource.StaticKeys}. */ -public final class StaticKeysHandler implements AwsConfigCredentialSourceHandler { +public final class StaticKeysHandler implements AwsConfigCredentialSourceHandler { public StaticKeysHandler() {} private static final Set FEATURE_IDS = Set.of(new CredentialFeatureId("n")); @Override + public Class identityType() { + return AwsCredentialsIdentity.class; + } + public Set featureIds() { return FEATURE_IDS; } 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.ChainIdentityProvider similarity index 100% rename from aws/aws-config/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider rename to aws/aws-config/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider 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 index bf7fd9cb2..5ea8e44ff 100644 --- 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 @@ -73,7 +73,7 @@ void unhandledSourceTypeYieldsTypedError(@TempDir Path tmp) throws IOException { source_profile = base """, StandardCharsets.UTF_8); - AwsProfileCredentialsResolver resolver = AwsProfileCredentialsResolver.builder() + var resolver = ProfileIdentityResolver.builder(AwsCredentialsIdentity.class) .configFile(config) .credentialsFile(null) .profileName("role-profile") @@ -84,7 +84,7 @@ void unhandledSourceTypeYieldsTypedError(@TempDir Path tmp) throws IOException { assertNotNull(result.error()); assertTrue(result.error().contains("AssumeRole")); assertTrue(result.error().contains("no handler")); - assertEquals(AwsProfileCredentialsResolver.class, result.resolver()); + assertEquals(ProfileIdentityResolver.class, result.resolver()); } @Test @@ -97,16 +97,28 @@ void customHandlerChainTakesOver(@TempDir Path tmp) throws IOException { 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() + AwsConfigCredentialSourceHandler stubAssumeRoleHandler = + new AwsConfigCredentialSourceHandler<>() { + @Override + public Class identityType() { + return AwsCredentialsIdentity.class; + } + + @Override + public IdentityResult tryResolve( + AwsConfigCredentialSource source, + ResolutionContext ctx + ) { + if (!(source instanceof AwsConfigCredentialSource.AssumeRole r)) { + return null; + } + return IdentityResult.of(AwsCredentialsIdentity.create( + "assumed-" + r.roleArn(), + "assumed-secret")); + } + }; + + var resolver = ProfileIdentityResolver.builder(AwsCredentialsIdentity.class) .configFile(config) .credentialsFile(null) .profileName("role-profile") @@ -132,7 +144,7 @@ void fallsThroughToNextSourceWhenFirstIsUnhandled(@TempDir Path tmp) throws IOEx aws_secret_access_key = FALLBACK_SK """, StandardCharsets.UTF_8); - AwsProfileCredentialsResolver resolver = AwsProfileCredentialsResolver.builder() + var resolver = ProfileIdentityResolver.builder(AwsCredentialsIdentity.class) .configFile(config) .credentialsFile(null) .profileName("mixed") @@ -156,7 +168,7 @@ void unhandledSourceFailsByDefault(@TempDir Path tmp) throws IOException { aws_secret_access_key = FALLBACK_SK """, StandardCharsets.UTF_8); - AwsProfileCredentialsResolver resolver = AwsProfileCredentialsResolver.builder() + var resolver = ProfileIdentityResolver.builder(AwsCredentialsIdentity.class) .configFile(config) .credentialsFile(null) .profileName("mixed") @@ -176,7 +188,7 @@ void profileWithoutRecognizedSourcesErrors(@TempDir Path tmp) throws IOException region = us-east-1 """, StandardCharsets.UTF_8); - AwsProfileCredentialsResolver resolver = AwsProfileCredentialsResolver.builder() + var resolver = ProfileIdentityResolver.builder(AwsCredentialsIdentity.class) .configFile(config) .credentialsFile(null) .profileName("default") @@ -210,7 +222,7 @@ void refreshReloadsCredentialsFromDisk(@TempDir Path tmp) throws IOException { aws_secret_access_key = S1 """); - AwsProfileCredentialsResolver resolver = buildResolver(creds, "default"); + var resolver = buildResolver(creds, "default"); assertEquals("V1", resolver.resolveIdentity(Context.empty()).unwrap().accessKeyId()); Files.writeString(creds, """ @@ -237,7 +249,7 @@ void canUsePreloadedProfileFile(@TempDir Path tmp) throws IOException { .credentialsFile(creds) .build(); - AwsProfileCredentialsResolver resolver = AwsProfileCredentialsResolver.builder() + var resolver = ProfileIdentityResolver.builder(AwsCredentialsIdentity.class) .profileFile(file) .profileName("prod") .build(); @@ -245,8 +257,8 @@ void canUsePreloadedProfileFile(@TempDir Path tmp) throws IOException { assertEquals("PK", resolver.resolveIdentity(Context.empty()).unwrap().accessKeyId()); } - private static AwsProfileCredentialsResolver buildResolver(Path credentials, String profile) { - return AwsProfileCredentialsResolver.builder() + private static ProfileIdentityResolver buildResolver(Path credentials, String profile) { + return ProfileIdentityResolver.builder(AwsCredentialsIdentity.class) .configFile(null) .credentialsFile(credentials) .profileName(profile) 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 index e632687e5..5222b2fb8 100644 --- 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 @@ -124,7 +124,7 @@ void endToEndWithResolver(@TempDir Path tmp) throws IOException { credential_process = %s """.formatted(script.toString()), StandardCharsets.UTF_8); - AwsProfileCredentialsResolver resolver = AwsProfileCredentialsResolver.builder() + var resolver = ProfileIdentityResolver.builder(AwsCredentialsIdentity.class) .configFile(config) .credentialsFile(null) .profileName("proc") diff --git a/aws/aws-credential-chain/README.md b/aws/aws-credential-chain/README.md index d09f53ee0..801e70521 100644 --- a/aws/aws-credential-chain/README.md +++ b/aws/aws-credential-chain/README.md @@ -1,7 +1,8 @@ -# AWS Credential Chain +# Credential Chain -Assembles an ordered AWS credential provider chain from SPI-discovered -providers. Provides the infrastructure for modular credential resolution. +Assembles an ordered identity provider chain from SPI-discovered providers. +Supports any identity type (AWS credentials, bearer tokens, etc.) through a +single generic chain. ## Dependency @@ -17,7 +18,7 @@ dependencies { Services modeled with `@aws.auth#sigv4` or `@aws.auth#sigv4a` automatically get `AwsCredentialChainPlugin` added as a default plugin during code generation. -No manual wiring is needed: just add the provider modules you need to your +No manual wiring needed - just add the provider modules you need to your runtime dependencies. ### Manual @@ -35,15 +36,18 @@ adds an interceptor that invalidates cached credentials on auth failures ## Standalone usage ```java -try (AwsCredentialChain chain = AwsCredentialChain.create()) { +try (var chain = CredentialChain.create(AwsCredentialsIdentity.class)) { IdentityResult result = chain.resolveIdentity(context); } ``` ## How it works -The chain discovers `AwsCredentialProvider` implementations via ServiceLoader, -orders them by builtin enum slots, and tries each in order until one succeeds. +The chain discovers `ChainIdentityProvider` implementations via ServiceLoader. +Each provider's `create(Class identityType, ProviderContext context)` is +called with the requested identity type. Providers that don't support the type +return `null` and are skipped. The chain tries remaining providers in order +until one succeeds. ## Builtin slots (in priority order) @@ -60,19 +64,18 @@ orders them by builtin enum slots, and tries each in order until one succeeds. Providers position themselves relative to builtin slots using `OrderingConstraint`: -- `Builtin(slot)` — claims a builtin slot (one provider per slot). Only one - provider can claim a builtin. Not all builtins have to be claimed. +- `Builtin(slot)` — claims a builtin slot (one provider per slot) - `Before(slot)` — inserts before the given slot's position - `After(slot)` — inserts after the given slot's position Before/After reference enum values only, so cycles are impossible. If the -referenced slot has no registered provider, the custom provider is placed -where that slot would be in enum order. +referenced slot has no registered provider, the custom provider is placed where +that slot would be in enum order. ## Adding a custom provider ```java -public class MyProvider implements AwsCredentialProvider { +public class MyProvider implements ChainIdentityProvider { @Override public String name() { return "MyCustomProvider"; @@ -84,10 +87,13 @@ public class MyProvider implements AwsCredentialProvider { } @Override - public IdentityResolver create(ProviderContext ctx) { - return new MyResolver(); + public IdentityResolver create(Class identityType, ProviderContext ctx) { + if (identityType == AwsCredentialsIdentity.class) { + return (IdentityResolver) new MyResolver(); + } + return null; } } ``` -Register in `META-INF/services/software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider`. +Register in `META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider`. diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialProvider.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialProvider.java deleted file mode 100644 index c3ffa9457..000000000 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialProvider.java +++ /dev/null @@ -1,45 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.java.aws.credentials.chain; - -import java.util.Set; -import software.amazon.smithy.java.auth.api.identity.IdentityResolver; -import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; - -/** - * SPI for registering a credential provider into the AWS default credential chain. - */ -public interface AwsCredentialProvider { - /** - * @return the unique name of this provider (for example {@code "environment"}, {@code "profile"}, {@code "imds"}). - */ - String name(); - - /** - * @return the ordering constraint for this provider. - */ - OrderingConstraint ordering(); - - /** - * Create the credential resolver for this provider. - * - *

    Called once during chain assembly. The returned resolver is used for the lifetime of the chain. - * - * @param context shared resources provided by the chain. - * @return the resolver. - */ - IdentityResolver create(ProviderContext context); - - /** - * The business metric feature ID emitted when this provider successfully resolves credentials. - * Appended to the User-Agent header per the credential chain precedence SEP. - * - * @return the feature ID, or {@code null} if none. - */ - default Set featureIds() { - return Set.of(); - } -} diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainIdentityProvider.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainIdentityProvider.java new file mode 100644 index 000000000..a85efa87d --- /dev/null +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainIdentityProvider.java @@ -0,0 +1,50 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.chain; + +import java.util.Set; +import software.amazon.smithy.java.auth.api.identity.Identity; +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; + +/** + * SPI for registering an identity provider into a credential/token chain. + * + *

    A single provider can support multiple identity types by checking the requested type in + * {@link #create(Class, ProviderContext)} and returning {@code null} for unsupported types. + */ +public interface ChainIdentityProvider { + /** + * @return the unique name of this provider. + */ + String name(); + + /** + * @return the ordering constraint for this provider. + */ + OrderingConstraint ordering(); + + /** + * The business metric feature IDs emitted when this provider successfully resolves an identity. + * + * @return the feature IDs, or empty if none. + */ + default Set featureIds() { + return Set.of(); + } + + /** + * Create the identity resolver for the requested identity type. + * + *

    Called once during chain assembly. If this provider does not support the requested identity + * type, it MUST return {@code null} and the chain will skip it. + * + * @param identityType the identity class the chain is resolving. + * @param context shared resources provided by the chain. + * @param the identity type. + * @return the resolver, or {@code null} if this provider does not support the requested type. + */ + IdentityResolver create(Class identityType, ProviderContext context); +} 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/CredentialChain.java similarity index 70% rename from aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChain.java rename to aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialChain.java index 001f75e64..7a73e0026 100644 --- 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/CredentialChain.java @@ -17,39 +17,49 @@ import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.stream.Collectors; +import software.amazon.smithy.java.auth.api.identity.Identity; import software.amazon.smithy.java.auth.api.identity.IdentityResolver; import software.amazon.smithy.java.auth.api.identity.IdentityResult; -import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; -import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsResolver; import software.amazon.smithy.java.client.core.CallContext; import software.amazon.smithy.java.context.Context; import software.amazon.smithy.java.logging.InternalLogger; /** - * The AWS default credential provider chain. + * A credential provider chain. * - *

    Discovers {@link AwsCredentialProvider} implementations via {@link ServiceLoader}, assembles them into an + *

    Discovers {@link ChainIdentityProvider} 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());
    + * var chain = CredentialChain.create();
    + * var result = chain.resolveIdentity(Context.empty());
      * }
    * *

    The chain is assembled once at creation time. Providers that are not on the classpath simply don't * participate: their slots are skipped. If no provider in the chain can resolve credentials, the chain returns an * error result describing which providers were tried. */ -public final class AwsCredentialChain implements AwsCredentialsResolver, AutoCloseable { +public final class CredentialChain implements IdentityResolver, AutoCloseable { - private static final InternalLogger LOGGER = InternalLogger.getLogger(AwsCredentialChain.class); + private static final InternalLogger LOGGER = InternalLogger.getLogger(CredentialChain.class); - private final List resolvers; + private final Class identityType; + private final List> resolvers; private final ScheduledExecutorService executor; - private AwsCredentialChain(List resolvers, ScheduledExecutorService executor) { + private record NamedResolver( + String name, + Set featureIds, + IdentityResolver resolver) {} + + private CredentialChain( + Class identityType, + List> resolvers, + ScheduledExecutorService executor + ) { + this.identityType = identityType; this.resolvers = resolvers; this.executor = executor; } @@ -57,33 +67,54 @@ private AwsCredentialChain(List resolvers, ScheduledExecutorServi /** * Create a credential chain by discovering providers via ServiceLoader. * + * @param identityType Identity type to resolve. * @return the assembled chain. * @throws IllegalStateException if two providers claim the same builtin slot. */ - public static AwsCredentialChain create() { - List registrations = new ArrayList<>(); - for (AwsCredentialProvider r : ServiceLoader.load(AwsCredentialProvider.class)) { + public static CredentialChain create(Class identityType) { + return create(identityType, Executors.newSingleThreadScheduledExecutor(r2 -> { + Thread t = new Thread(r2, "aws-credential-chain-refresh"); + t.setDaemon(true); + return t; + })); + } + + /** + * Create a credential chain by discovering providers via ServiceLoader. + * + * @param identityType Identity type to resolve. + * @param ex Executor used for background resolution. + * @return the assembled chain. + * @throws IllegalStateException if two providers claim the same builtin slot. + */ + public static CredentialChain create(Class identityType, ScheduledExecutorService ex) { + List registrations = new ArrayList<>(); + for (ChainIdentityProvider r : ServiceLoader.load(ChainIdentityProvider.class)) { registrations.add(r); } - return assemble(registrations); + return assemble(identityType, registrations, ex); } - static AwsCredentialChain assemble(List registrations) { + static CredentialChain assemble( + Class identityType, + List registrations, + ScheduledExecutorService executor + ) { // Check for duplicate names. Set seenNames = new HashSet<>(); - for (AwsCredentialProvider r : registrations) { + for (ChainIdentityProvider r : registrations) { if (!seenNames.add(r.name())) { throw new IllegalStateException("Duplicate credential provider registration name: '" + r.name() + "'"); } } // Separate builtins from relatives. - Map builtins = new EnumMap<>(BuiltinProvider.class); - List relatives = new ArrayList<>(); + Map builtins = new EnumMap<>(BuiltinProvider.class); + List relatives = new ArrayList<>(); - for (AwsCredentialProvider r : registrations) { + for (ChainIdentityProvider r : registrations) { if (r.ordering() instanceof OrderingConstraint.Builtin(BuiltinProvider slot)) { - AwsCredentialProvider existing = builtins.put(slot, r); + ChainIdentityProvider existing = builtins.put(slot, r); if (existing != null) { throw new IllegalStateException("Two credential providers claim the same slot '" + slot + "': '" + existing.name() + "' and '" + r.name() + "'"); @@ -94,11 +125,6 @@ static AwsCredentialChain assemble(List registrations) { } // Use a single executor for each provider (used for caching). - ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor(r2 -> { - Thread t = new Thread(r2, "aws-credential-chain-refresh"); - t.setDaemon(true); - return t; - }); ProviderContext ctx = new ProviderContext(executor, Context.create()); // Precompute insert positions: for each slot, how many claimed slots come before it @@ -115,16 +141,19 @@ static AwsCredentialChain assemble(List registrations) { } // Build the ordered list: builtin slots in enum order. - List ordered = new ArrayList<>(); + List> ordered = new ArrayList<>(); for (BuiltinProvider slot : BuiltinProvider.values()) { - AwsCredentialProvider r = builtins.get(slot); + ChainIdentityProvider r = builtins.get(slot); if (r != null) { - ordered.add(new NamedResolver(r.name(), r.featureIds(), r.create(ctx))); + IdentityResolver resolver = r.create(identityType, ctx); + if (resolver != null) { + ordered.add(new NamedResolver<>(r.name(), r.featureIds(), resolver)); + } } } // Insert relative providers using precomputed positions. - for (AwsCredentialProvider r : relatives) { + for (ChainIdentityProvider r : relatives) { int insertAt; if (r.ordering() instanceof OrderingConstraint.After(BuiltinProvider slot)) { insertAt = insertAfter.get(slot); @@ -136,7 +165,10 @@ static AwsCredentialChain assemble(List registrations) { if (insertAt > ordered.size()) { insertAt = ordered.size(); } - ordered.add(insertAt, new NamedResolver(r.name(), r.featureIds(), r.create(ctx))); + IdentityResolver relResolver = r.create(identityType, ctx); + if (relResolver != null) { + ordered.add(insertAt, new NamedResolver<>(r.name(), r.featureIds(), relResolver)); + } } if (LOGGER.isDebugEnabled()) { @@ -145,10 +177,10 @@ static AwsCredentialChain assemble(List registrations) { } warnDetectedButUnclaimed(builtins); - return new AwsCredentialChain(Collections.unmodifiableList(ordered), executor); + return new CredentialChain<>(identityType, Collections.unmodifiableList(ordered), executor); } - private static void warnDetectedButUnclaimed(Map builtins) { + private static void warnDetectedButUnclaimed(Map builtins) { for (BuiltinProvider slot : BuiltinProvider.values()) { if (slot.moduleSuggestion() != null && !builtins.containsKey(slot) && slot.isDetected()) { LOGGER.warn("{} credentials detected but no provider is registered for the '{}' slot. " @@ -161,7 +193,7 @@ private static void warnDetectedButUnclaimed(Map resolveIdentity(Context requestProperties) { + public IdentityResult resolveIdentity(Context requestProperties) { if (resolvers.isEmpty()) { return IdentityResult.ofError(getClass(), "No credential providers were discovered. Ensure at least one " @@ -171,8 +203,8 @@ public IdentityResult resolveIdentity(Context requestPro // More cheaply build up a list of failures, and defer string-ing them into a StringBuilder. List errors = new ArrayList<>(); - for (NamedResolver nr : resolvers) { - IdentityResult result = nr.resolver.resolveIdentity(requestProperties); + for (var nr : resolvers) { + var result = nr.resolver.resolveIdentity(requestProperties); if (result.identity() != null) { if (!nr.featureIds.isEmpty()) { var ids = requestProperties.get(CallContext.FEATURE_IDS); @@ -216,7 +248,7 @@ private String detectedButMissingHints() { } private boolean isClaimed(BuiltinProvider slot) { - for (NamedResolver nr : resolvers) { + for (var nr : resolvers) { if (nr.name.equals(slot.name().toLowerCase(Locale.ROOT))) { return true; } @@ -229,15 +261,20 @@ private boolean isClaimed(BuiltinProvider slot) { */ public List providerNames() { List names = new ArrayList<>(resolvers.size()); - for (NamedResolver nr : resolvers) { + for (var nr : resolvers) { names.add(nr.name); } return names; } + @Override + public Class identityType() { + return identityType; + } + @Override public void invalidate() { - for (NamedResolver nr : resolvers) { + for (var nr : resolvers) { nr.resolver.invalidate(); } } @@ -246,9 +283,4 @@ public void invalidate() { public void close() { executor.shutdownNow(); } - - private record NamedResolver( - String name, - Set featureIds, - IdentityResolver resolver) {} } diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/OrderingConstraint.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/OrderingConstraint.java index 297d20e2d..1a0ac30fb 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/OrderingConstraint.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/OrderingConstraint.java @@ -6,7 +6,7 @@ package software.amazon.smithy.java.aws.credentials.chain; /** - * Describes where an {@link AwsCredentialProvider} sits in the credential chain. + * Describes where a {@link ChainIdentityProvider} sits in the credential chain. * *

    Three forms: *

      diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ProviderContext.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ProviderContext.java index 9957e8db1..ed573838c 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ProviderContext.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ProviderContext.java @@ -9,14 +9,7 @@ import software.amazon.smithy.java.context.Context; /** - * Context passed to {@link AwsCredentialProvider#create(ProviderContext)} during chain assembly. - * - *

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

        - *
      • A shared {@link ScheduledExecutorService} for background credential refresh tasks.
      • - *
      • A {@link Context} property bag for sharing data between providers (e.g., a parsed - * config file).
      • - *
      + * Context passed to {@link ChainIdentityProvider#create)} during chain assembly. * * @param executor shared executor for background refresh. * @param properties shared property bag for cross-provider data. diff --git a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java index 3a8ab7920..3a627484f 100644 --- a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java +++ b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java @@ -13,37 +13,42 @@ import java.util.List; import org.junit.jupiter.api.Test; +import software.amazon.smithy.java.auth.api.identity.Identity; +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; import software.amazon.smithy.java.auth.api.identity.IdentityResult; import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; -import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsResolver; import software.amazon.smithy.java.context.Context; class AwsCredentialChainTest { @Test void builtinProvidersAreOrderedByEnumOrder() { - var chain = AwsCredentialChain.assemble(List.of( - registration("imds", - new OrderingConstraint.Builtin(BuiltinProvider.EC2_INSTANCE_METADATA), - errorResolver("imds")), - registration("env", - new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), - errorResolver("env")), - registration("profile", - new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), - errorResolver("profile")))); + var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, + List.of( + registration("imds", + new OrderingConstraint.Builtin(BuiltinProvider.EC2_INSTANCE_METADATA), + errorResolver("imds")), + registration("env", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("env")), + registration("profile", + new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), + errorResolver("profile"))), + null); assertEquals(List.of("env", "profile", "imds"), chain.providerNames()); } @Test void firstSuccessfulProviderWins() { - var chain = AwsCredentialChain.assemble(List.of( - registration("env", - new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), - errorResolver("env")), - registration("profile", - new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), - staticResolver("AK", "SK")))); + var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, + List.of( + registration("env", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("env")), + registration("profile", + new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), + staticResolver("AK", "SK"))), + null); IdentityResult result = chain.resolveIdentity(Context.empty()); assertNotNull(result.identity()); @@ -52,13 +57,15 @@ void firstSuccessfulProviderWins() { @Test void allFailReturnsAggregatedError() { - var chain = AwsCredentialChain.assemble(List.of( - registration("env", - new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), - errorResolver("no env")), - registration("profile", - new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), - errorResolver("no profile")))); + var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, + List.of( + registration("env", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("no env")), + registration("profile", + new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), + errorResolver("no profile"))), + null); IdentityResult result = chain.resolveIdentity(Context.empty()); assertNull(result.identity()); @@ -69,86 +76,97 @@ void allFailReturnsAggregatedError() { @Test void duplicateSlotThrows() { assertThrows(IllegalStateException.class, - () -> AwsCredentialChain.assemble(List.of( - registration("a", - new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), - errorResolver("a")), - registration("b", - new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), - errorResolver("b"))))); + () -> CredentialChain.assemble(AwsCredentialsIdentity.class, + List.of( + registration("a", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("a")), + registration("b", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("b"))), + null)); } @Test void relativeAfterInsertsCorrectly() { - var chain = AwsCredentialChain.assemble(List.of( - registration("env", - new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), - errorResolver("env")), - registration("profile", - new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), - errorResolver("profile")), - registration("custom", - new OrderingConstraint.After(BuiltinProvider.ENVIRONMENT), - errorResolver("custom")))); + var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, + List.of( + registration("env", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("env")), + registration("profile", + new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), + errorResolver("profile")), + registration("custom", + new OrderingConstraint.After(BuiltinProvider.ENVIRONMENT), + errorResolver("custom"))), + null); assertEquals(List.of("env", "custom", "profile"), chain.providerNames()); } @Test void relativeBeforeInsertsCorrectly() { - var chain = AwsCredentialChain.assemble(List.of( - registration("env", - new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), - errorResolver("env")), - registration("profile", - new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), - errorResolver("profile")), - registration("custom", - new OrderingConstraint.Before(BuiltinProvider.SHARED_CONFIG), - errorResolver("custom")))); + var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, + List.of( + registration("env", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("env")), + registration("profile", + new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), + errorResolver("profile")), + registration("custom", + new OrderingConstraint.Before(BuiltinProvider.SHARED_CONFIG), + errorResolver("custom"))), + null); assertEquals(List.of("env", "custom", "profile"), chain.providerNames()); } @Test void relativeToUnclaimedSlotAppendsAtEnd() { - var chain = AwsCredentialChain.assemble(List.of( - registration("env", - new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), - errorResolver("env")), - registration("custom", - new OrderingConstraint.After(BuiltinProvider.EC2_INSTANCE_METADATA), - errorResolver("custom")))); + var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, + List.of( + registration("env", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("env")), + registration("custom", + new OrderingConstraint.After(BuiltinProvider.EC2_INSTANCE_METADATA), + errorResolver("custom"))), + null); + assertEquals(List.of("env", "custom"), chain.providerNames()); } @Test void duplicateNameThrows() { assertThrows(IllegalStateException.class, - () -> AwsCredentialChain.assemble(List.of( - registration("env", - new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), - errorResolver("env")), - registration("env", - new OrderingConstraint.Builtin(BuiltinProvider.JAVA_SYSTEM_PROPERTIES), - errorResolver("env2"))))); + () -> CredentialChain.assemble(AwsCredentialsIdentity.class, + List.of( + registration("env", + new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + errorResolver("env")), + registration("env", + new OrderingConstraint.Builtin(BuiltinProvider.JAVA_SYSTEM_PROPERTIES), + errorResolver("env2"))), + null)); } @Test void emptyChainReturnsDescriptiveError() { - var chain = AwsCredentialChain.assemble(List.of()); + var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, List.of(), null); IdentityResult result = chain.resolveIdentity(Context.empty()); assertNull(result.identity()); assertTrue(result.error().contains("No credential providers were discovered")); } - private static AwsCredentialProvider registration( + private static ChainIdentityProvider registration( String name, OrderingConstraint ordering, - AwsCredentialsResolver resolver + IdentityResolver resolver ) { - return new AwsCredentialProvider() { + return new ChainIdentityProvider() { @Override public String name() { return name; @@ -160,17 +178,40 @@ public OrderingConstraint ordering() { } @Override - public AwsCredentialsResolver create(ProviderContext context) { - return resolver; + @SuppressWarnings("unchecked") + public IdentityResolver create(Class identityType, ProviderContext context) { + return (IdentityResolver) resolver; } }; } - private static AwsCredentialsResolver errorResolver(String msg) { - return ctx -> IdentityResult.ofError(AwsCredentialChainTest.class, msg); + private static IdentityResolver errorResolver(String msg) { + IdentityResult result = IdentityResult.ofError(AwsCredentialChainTest.class, msg); + return new IdentityResolver<>() { + @Override + public IdentityResult resolveIdentity(Context ctx) { + return result; + } + + @Override + public Class identityType() { + return AwsCredentialsIdentity.class; + } + }; } - private static AwsCredentialsResolver staticResolver(String ak, String sk) { - return ctx -> IdentityResult.of(AwsCredentialsIdentity.create(ak, sk)); + private static IdentityResolver staticResolver(String ak, String sk) { + IdentityResult result = IdentityResult.of(AwsCredentialsIdentity.create(ak, sk)); + return new IdentityResolver<>() { + @Override + public IdentityResult resolveIdentity(Context ctx) { + return result; + } + + @Override + public Class identityType() { + return AwsCredentialsIdentity.class; + } + }; } } diff --git a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java index e9191a366..2ee15b0ba 100644 --- a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java +++ b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java @@ -12,6 +12,7 @@ import java.util.List; import java.util.Set; import org.junit.jupiter.api.Test; +import software.amazon.smithy.java.auth.api.identity.Identity; import software.amazon.smithy.java.auth.api.identity.IdentityResolver; import software.amazon.smithy.java.auth.api.identity.IdentityResult; import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; @@ -23,11 +24,13 @@ class FeatureIdTest { @Test void successfulProviderEmitsFeatureId() { - var chain = AwsCredentialChain.assemble(List.of( - provider("env", - BuiltinProvider.ENVIRONMENT, - Set.of(new CredentialFeatureId("g")), - staticResolver("AK", "SK")))); + var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, + List.of( + provider("env", + BuiltinProvider.ENVIRONMENT, + Set.of(new CredentialFeatureId("g")), + staticResolver("AK", "SK"))), + null); Context ctx = Context.create(); ctx.put(CallContext.FEATURE_IDS, new HashSet<>()); @@ -41,15 +44,17 @@ void successfulProviderEmitsFeatureId() { @Test void failedProviderDoesNotEmitFeatureId() { - var chain = AwsCredentialChain.assemble(List.of( - provider("env", - BuiltinProvider.ENVIRONMENT, - Set.of(new CredentialFeatureId("g")), - errorResolver("no creds")), - provider("profile", - BuiltinProvider.SHARED_CONFIG, - Set.of(new CredentialFeatureId("n")), - staticResolver("AK", "SK")))); + var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, + List.of( + provider("env", + BuiltinProvider.ENVIRONMENT, + Set.of(new CredentialFeatureId("g")), + errorResolver("no creds")), + provider("profile", + BuiltinProvider.SHARED_CONFIG, + Set.of(new CredentialFeatureId("n")), + staticResolver("AK", "SK"))), + null); Context ctx = Context.create(); ctx.put(CallContext.FEATURE_IDS, new HashSet<>()); @@ -63,11 +68,13 @@ void failedProviderDoesNotEmitFeatureId() { @Test void multipleFeatureIdsEmitted() { - var chain = AwsCredentialChain.assemble(List.of( - provider("proc", - BuiltinProvider.SHARED_CONFIG, - Set.of(new CredentialFeatureId("v"), new CredentialFeatureId("w")), - staticResolver("AK", "SK")))); + var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, + List.of( + provider("proc", + BuiltinProvider.SHARED_CONFIG, + Set.of(new CredentialFeatureId("v"), new CredentialFeatureId("w")), + staticResolver("AK", "SK"))), + null); Context ctx = Context.create(); ctx.put(CallContext.FEATURE_IDS, new HashSet<>()); @@ -86,11 +93,13 @@ void multipleFeatureIdsEmitted() { @Test void noFeatureIdsWhenContextKeyNotSet() { - var chain = AwsCredentialChain.assemble(List.of( - provider("env", - BuiltinProvider.ENVIRONMENT, - Set.of(new CredentialFeatureId("g")), - staticResolver("AK", "SK")))); + var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, + List.of( + provider("env", + BuiltinProvider.ENVIRONMENT, + Set.of(new CredentialFeatureId("g")), + staticResolver("AK", "SK"))), + null); // No FEATURE_IDS in context — should not throw. Context ctx = Context.create(); @@ -98,13 +107,13 @@ void noFeatureIdsWhenContextKeyNotSet() { assertEquals("AK", result.identity().accessKeyId()); } - private static AwsCredentialProvider provider( + private static ChainIdentityProvider provider( String name, BuiltinProvider slot, Set featureIds, IdentityResolver resolver ) { - return new AwsCredentialProvider() { + return new ChainIdentityProvider() { @Override public String name() { return name; @@ -121,8 +130,9 @@ public OrderingConstraint ordering() { } @Override - public IdentityResolver create(ProviderContext context) { - return resolver; + @SuppressWarnings("unchecked") + public IdentityResolver create(Class identityType, ProviderContext context) { + return (IdentityResolver) resolver; } }; } diff --git a/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java b/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java index 35cd480a2..62c4de2db 100644 --- a/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java +++ b/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java @@ -12,14 +12,15 @@ import java.time.format.DateTimeParseException; import java.util.Set; import software.amazon.smithy.java.auth.api.identity.CachingIdentityResolver; +import software.amazon.smithy.java.auth.api.identity.Identity; import software.amazon.smithy.java.auth.api.identity.IdentityResolver; import software.amazon.smithy.java.auth.api.identity.IdentityResult; import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsResolver; import software.amazon.smithy.java.aws.config.AwsProfile; import software.amazon.smithy.java.aws.config.AwsProfileFile; -import software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider; import software.amazon.smithy.java.aws.credentials.chain.BuiltinProvider; +import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; @@ -34,7 +35,7 @@ *

      Registers in the {@link BuiltinProvider#EC2_INSTANCE_METADATA} chain slot. Uses IMDSv2 exclusively * (no v1 fallback). Credentials are cached with static stability enabled per the AWS Static Stability SEP. */ -public final class ImdsCredentialProvider implements AwsCredentialProvider { +public final class ImdsCredentialProvider implements ChainIdentityProvider { private static final Set FEATURE_IDS = Set.of(new CredentialFeatureId("0")); @@ -58,10 +59,15 @@ public OrderingConstraint ordering() { } @Override - public IdentityResolver create(ProviderContext context) { + @SuppressWarnings("unchecked") + public IdentityResolver create(Class identityType, ProviderContext context) { + if (identityType != AwsCredentialsIdentity.class) { + return null; + } + AwsProfileFile profileFile = context.properties().get(AwsProfileFile.CONTEXT_KEY); if (isDisabled(profileFile)) { - return new DisabledResolver(); + return (IdentityResolver) new DisabledResolver(); } URI endpoint = resolveEndpoint(); @@ -69,9 +75,9 @@ public IdentityResolver create(ProviderContext context) ImdsClient client = new ImdsClient(endpoint); AwsCredentialsResolver delegate = ctx -> fetchAndParse(client, profileName); - return CachingIdentityResolver.builder(delegate) + return (IdentityResolver) CachingIdentityResolver.builder(delegate) .executor(context.executor()) - .allowExpiredCredentials(true) // Static stability + .allowExpiredCredentials(true) .build(); } diff --git a/aws/aws-credentials-imds/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider b/aws/aws-credentials-imds/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider similarity index 100% rename from aws/aws-credentials-imds/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider rename to aws/aws-credentials-imds/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/AwsCredentialChainPlugin.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/AwsCredentialChainPlugin.java index e591ce64b..df5aa794d 100644 --- a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/AwsCredentialChainPlugin.java +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/AwsCredentialChainPlugin.java @@ -7,7 +7,7 @@ import software.amazon.smithy.java.auth.api.identity.IdentityResolver; import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; -import software.amazon.smithy.java.aws.credentials.chain.AwsCredentialChain; +import software.amazon.smithy.java.aws.credentials.chain.CredentialChain; import software.amazon.smithy.java.client.core.ClientConfig; import software.amazon.smithy.java.client.core.ClientPlugin; import software.amazon.smithy.java.client.core.auth.scheme.AuthScheme; @@ -38,7 +38,7 @@ public Phase getPluginPhase() { @Override public void configureClient(ClientConfig.Builder config) { if (needsAwsCredentials(config) && !hasAwsCredentialsResolver(config)) { - var chain = AwsCredentialChain.create(); + var chain = CredentialChain.create(AwsCredentialsIdentity.class); config.addIdentityResolver(chain); config.addInterceptor(new InvalidateOnAuthFailureInterceptor(chain)); } diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java index eec1b0d30..ecc963e5b 100644 --- a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java @@ -6,19 +6,16 @@ package software.amazon.smithy.java.aws.client.core.identity; import java.util.Set; +import software.amazon.smithy.java.auth.api.identity.Identity; import software.amazon.smithy.java.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.ChainIdentityProvider; import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; -/** - * Registers {@link EnvironmentVariableIdentityResolver} in the credential chain's - * {@link BuiltinProvider#ENVIRONMENT} slot. - */ -public final class EnvironmentCredentialProvider implements AwsCredentialProvider { +public final class EnvironmentCredentialProvider implements ChainIdentityProvider { private static final Set FEATURE_IDS = Set.of(new CredentialFeatureId("g")); @@ -38,7 +35,11 @@ public OrderingConstraint ordering() { } @Override - public IdentityResolver create(ProviderContext context) { - return EnvironmentVariableIdentityResolver.INSTANCE; + @SuppressWarnings("unchecked") + public IdentityResolver create(Class identityType, ProviderContext context) { + if (identityType == AwsCredentialsIdentity.class) { + return (IdentityResolver) EnvironmentVariableIdentityResolver.INSTANCE; + } + return null; } } diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java index eef2bc13e..320211e65 100644 --- a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java @@ -6,19 +6,16 @@ package software.amazon.smithy.java.aws.client.core.identity; import java.util.Set; +import software.amazon.smithy.java.auth.api.identity.Identity; import software.amazon.smithy.java.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.ChainIdentityProvider; import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; -/** - * Registers {@link SystemPropertiesIdentityResolver} in the credential chain's - * {@link BuiltinProvider#JAVA_SYSTEM_PROPERTIES} slot. - */ -public final class SystemPropertiesCredentialProvider implements AwsCredentialProvider { +public final class SystemPropertiesCredentialProvider implements ChainIdentityProvider { private static final Set FEATURE_IDS = Set.of(new CredentialFeatureId("f")); @@ -38,7 +35,11 @@ public OrderingConstraint ordering() { } @Override - public IdentityResolver create(ProviderContext context) { - return SystemPropertiesIdentityResolver.INSTANCE; + @SuppressWarnings("unchecked") + public IdentityResolver create(Class identityType, ProviderContext context) { + if (identityType == AwsCredentialsIdentity.class) { + return (IdentityResolver) SystemPropertiesIdentityResolver.INSTANCE; + } + return null; } } diff --git a/aws/client/aws-client-core/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider b/aws/client/aws-client-core/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider similarity index 100% rename from aws/client/aws-client-core/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.AwsCredentialProvider rename to aws/client/aws-client-core/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider From a6b0acc53596c4d79bf3cda3e1ae2af800475cb7 Mon Sep 17 00:00:00 2001 From: Michael Dowling Date: Tue, 12 May 2026 16:39:14 -0500 Subject: [PATCH 10/13] Rename Builtin to StandardProvider --- .../aws/config/ProfileCredentialProvider.java | 6 +-- aws/aws-credential-chain/README.md | 8 ++-- .../credentials/chain/CredentialChain.java | 44 +++++++++---------- .../credentials/chain/OrderingConstraint.java | 28 ++++++------ ...tinProvider.java => StandardProvider.java} | 6 +-- .../chain/AwsCredentialChainTest.java | 40 ++++++++--------- .../aws/credentials/chain/FeatureIdTest.java | 14 +++--- .../imds/ImdsCredentialProvider.java | 6 +-- .../EnvironmentCredentialProvider.java | 4 +- .../SystemPropertiesCredentialProvider.java | 4 +- 10 files changed, 80 insertions(+), 80 deletions(-) rename aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/{BuiltinProvider.java => StandardProvider.java} (95%) 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 index 4ad90cb82..655702b1e 100644 --- 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 @@ -9,14 +9,14 @@ import software.amazon.smithy.java.auth.api.identity.Identity; import software.amazon.smithy.java.auth.api.identity.IdentityResolver; import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; -import software.amazon.smithy.java.aws.credentials.chain.BuiltinProvider; import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; +import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; /** * Registers {@link ProfileIdentityResolver} in the credential chain's - * {@link BuiltinProvider#SHARED_CONFIG} slot. + * {@link StandardProvider#SHARED_CONFIG} slot. */ public final class ProfileCredentialProvider implements ChainIdentityProvider { @Override @@ -26,7 +26,7 @@ public String name() { @Override public OrderingConstraint ordering() { - return new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG); + return new OrderingConstraint.Standard(StandardProvider.SHARED_CONFIG); } @Override diff --git a/aws/aws-credential-chain/README.md b/aws/aws-credential-chain/README.md index 801e70521..e83abd0e4 100644 --- a/aws/aws-credential-chain/README.md +++ b/aws/aws-credential-chain/README.md @@ -49,7 +49,7 @@ called with the requested identity type. Providers that don't support the type return `null` and are skipped. The chain tries remaining providers in order until one succeeds. -## Builtin slots (in priority order) +## Standard slots (in priority order) 1. `CODE` — programmatic 2. `JAVA_SYSTEM_PROPERTIES` — `aws.accessKeyId` @@ -61,10 +61,10 @@ until one succeeds. ## Ordering -Providers position themselves relative to builtin slots using +Providers position themselves relative to standard slots using `OrderingConstraint`: -- `Builtin(slot)` — claims a builtin slot (one provider per slot) +- `Standard(slot)` — claims a standard slot (one provider per slot) - `Before(slot)` — inserts before the given slot's position - `After(slot)` — inserts after the given slot's position @@ -83,7 +83,7 @@ public class MyProvider implements ChainIdentityProvider { @Override public OrderingConstraint ordering() { - return new OrderingConstraint.After(BuiltinProvider.SHARED_CONFIG); + return new OrderingConstraint.After(StandardProvider.SHARED_CONFIG); } @Override diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialChain.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialChain.java index 7a73e0026..5a8673fc2 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialChain.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialChain.java @@ -28,7 +28,7 @@ * A credential provider chain. * *

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

      Usage: @@ -69,7 +69,7 @@ private CredentialChain( * * @param identityType Identity type to resolve. * @return the assembled chain. - * @throws IllegalStateException if two providers claim the same builtin slot. + * @throws IllegalStateException if two providers claim the same standard slot. */ public static CredentialChain create(Class identityType) { return create(identityType, Executors.newSingleThreadScheduledExecutor(r2 -> { @@ -85,7 +85,7 @@ public static CredentialChain create(Class identityTy * @param identityType Identity type to resolve. * @param ex Executor used for background resolution. * @return the assembled chain. - * @throws IllegalStateException if two providers claim the same builtin slot. + * @throws IllegalStateException if two providers claim the same standard slot. */ public static CredentialChain create(Class identityType, ScheduledExecutorService ex) { List registrations = new ArrayList<>(); @@ -108,13 +108,13 @@ static CredentialChain assemble( } } - // Separate builtins from relatives. - Map builtins = new EnumMap<>(BuiltinProvider.class); + // Separate standards from relatives. + Map standards = new EnumMap<>(StandardProvider.class); List relatives = new ArrayList<>(); for (ChainIdentityProvider r : registrations) { - if (r.ordering() instanceof OrderingConstraint.Builtin(BuiltinProvider slot)) { - ChainIdentityProvider existing = builtins.put(slot, r); + if (r.ordering() instanceof OrderingConstraint.Standard(StandardProvider slot)) { + ChainIdentityProvider existing = standards.put(slot, r); if (existing != null) { throw new IllegalStateException("Two credential providers claim the same slot '" + slot + "': '" + existing.name() + "' and '" + r.name() + "'"); @@ -129,21 +129,21 @@ static CredentialChain assemble( // Precompute insert positions: for each slot, how many claimed slots come before it // and up to and including it. This avoids re-scanning the enum on every relative insert. - EnumMap insertAfter = new EnumMap<>(BuiltinProvider.class); - EnumMap insertBefore = new EnumMap<>(BuiltinProvider.class); + EnumMap insertAfter = new EnumMap<>(StandardProvider.class); + EnumMap insertBefore = new EnumMap<>(StandardProvider.class); int count = 0; - for (BuiltinProvider slot : BuiltinProvider.values()) { + for (StandardProvider slot : StandardProvider.values()) { insertBefore.put(slot, count); - if (builtins.containsKey(slot)) { + if (standards.containsKey(slot)) { count++; } insertAfter.put(slot, count); } - // Build the ordered list: builtin slots in enum order. + // Build the ordered list: standard slots in enum order. List> ordered = new ArrayList<>(); - for (BuiltinProvider slot : BuiltinProvider.values()) { - ChainIdentityProvider r = builtins.get(slot); + for (StandardProvider slot : StandardProvider.values()) { + ChainIdentityProvider r = standards.get(slot); if (r != null) { IdentityResolver resolver = r.create(identityType, ctx); if (resolver != null) { @@ -155,9 +155,9 @@ static CredentialChain assemble( // Insert relative providers using precomputed positions. for (ChainIdentityProvider r : relatives) { int insertAt; - if (r.ordering() instanceof OrderingConstraint.After(BuiltinProvider slot)) { + if (r.ordering() instanceof OrderingConstraint.After(StandardProvider slot)) { insertAt = insertAfter.get(slot); - } else if (r.ordering() instanceof OrderingConstraint.Before(BuiltinProvider slot)) { + } else if (r.ordering() instanceof OrderingConstraint.Before(StandardProvider slot)) { insertAt = insertBefore.get(slot); } else { insertAt = ordered.size(); @@ -176,13 +176,13 @@ static CredentialChain assemble( ordered.stream().map(NamedResolver::name).collect(Collectors.joining(", "))); } - warnDetectedButUnclaimed(builtins); + warnDetectedButUnclaimed(standards); return new CredentialChain<>(identityType, Collections.unmodifiableList(ordered), executor); } - private static void warnDetectedButUnclaimed(Map builtins) { - for (BuiltinProvider slot : BuiltinProvider.values()) { - if (slot.moduleSuggestion() != null && !builtins.containsKey(slot) && slot.isDetected()) { + private static void warnDetectedButUnclaimed(Map standards) { + for (StandardProvider slot : StandardProvider.values()) { + if (slot.moduleSuggestion() != null && !standards.containsKey(slot) && slot.isDetected()) { LOGGER.warn("{} credentials detected but no provider is registered for the '{}' slot. " + "Add '{}' to your dependencies.", slot.name(), @@ -233,7 +233,7 @@ public IdentityResult resolveIdentity(Context requestProperties) { private String detectedButMissingHints() { StringBuilder hints = new StringBuilder(); - for (BuiltinProvider slot : BuiltinProvider.values()) { + for (StandardProvider slot : StandardProvider.values()) { if (slot.moduleSuggestion() != null && slot.isDetected()) { if (!isClaimed(slot)) { hints.append(" Detected ") @@ -247,7 +247,7 @@ private String detectedButMissingHints() { return hints.toString(); } - private boolean isClaimed(BuiltinProvider slot) { + private boolean isClaimed(StandardProvider slot) { for (var nr : resolvers) { if (nr.name.equals(slot.name().toLowerCase(Locale.ROOT))) { return true; diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/OrderingConstraint.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/OrderingConstraint.java index 1a0ac30fb..284fbdfbf 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/OrderingConstraint.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/OrderingConstraint.java @@ -10,23 +10,23 @@ * *

      Three forms: *

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

      {@link Before} and {@link After} reference {@link BuiltinProvider} enum values only, not + *

      {@link Before} and {@link After} reference {@link StandardProvider} enum values only, not * arbitrary provider names. This eliminates the possibility of cycles in ordering constraints. */ public sealed interface OrderingConstraint { /** - * Claims a builtin slot in the default chain. Only one provider may claim each slot. + * Claims a standard slot in the default chain. Only one provider may claim each slot. * - * @param slot the builtin slot to claim. + * @param slot the standard slot to claim. */ - record Builtin(BuiltinProvider slot) implements OrderingConstraint { - public Builtin { + record Standard(StandardProvider slot) implements OrderingConstraint { + public Standard { if (slot == null) { throw new IllegalArgumentException("slot must not be null"); } @@ -34,11 +34,11 @@ record Builtin(BuiltinProvider slot) implements OrderingConstraint { } /** - * Positions a provider immediately before the given builtin slot. + * Positions a provider immediately before the given standard slot. * - * @param slot the builtin slot this provider must come before. + * @param slot the standard slot this provider must come before. */ - record Before(BuiltinProvider slot) implements OrderingConstraint { + record Before(StandardProvider slot) implements OrderingConstraint { public Before { if (slot == null) { throw new IllegalArgumentException("slot must not be null"); @@ -47,11 +47,11 @@ record Before(BuiltinProvider slot) implements OrderingConstraint { } /** - * Positions a provider immediately after the given builtin slot. + * Positions a provider immediately after the given standard slot. * - * @param slot the builtin slot this provider must come after. + * @param slot the standard slot this provider must come after. */ - record After(BuiltinProvider slot) implements OrderingConstraint { + record After(StandardProvider slot) implements OrderingConstraint { public After { if (slot == null) { throw new IllegalArgumentException("slot must not be null"); diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/BuiltinProvider.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/StandardProvider.java similarity index 95% rename from aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/BuiltinProvider.java rename to aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/StandardProvider.java index 3cfbdb566..607c2da62 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/BuiltinProvider.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/StandardProvider.java @@ -9,7 +9,7 @@ import java.nio.file.Path; /** - * Builtin credential provider slots in the AWS default credential chain. + * Standard credential provider slots in the AWS default credential chain. * *

      These are ordered from highest to lowest priority. If no implementation is registered for a slot, that slot is * skipped in the chain. @@ -18,7 +18,7 @@ * (via {@link #isDetected()}), and what dependency to suggest if the implementation is missing * (via {@link #moduleSuggestion()}). */ -public enum BuiltinProvider { +public enum StandardProvider { /** Credentials explicitly provided in code. */ CODE(null) { @Override @@ -84,7 +84,7 @@ public boolean isDetected() { private final String moduleSuggestion; - BuiltinProvider(String moduleSuggestion) { + StandardProvider(String moduleSuggestion) { this.moduleSuggestion = moduleSuggestion; } diff --git a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java index 3a627484f..b867a1767 100644 --- a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java +++ b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java @@ -21,17 +21,17 @@ class AwsCredentialChainTest { @Test - void builtinProvidersAreOrderedByEnumOrder() { + void standardProvidersAreOrderedByEnumOrder() { var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, List.of( registration("imds", - new OrderingConstraint.Builtin(BuiltinProvider.EC2_INSTANCE_METADATA), + new OrderingConstraint.Standard(StandardProvider.EC2_INSTANCE_METADATA), errorResolver("imds")), registration("env", - new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + new OrderingConstraint.Standard(StandardProvider.ENVIRONMENT), errorResolver("env")), registration("profile", - new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), + new OrderingConstraint.Standard(StandardProvider.SHARED_CONFIG), errorResolver("profile"))), null); @@ -43,10 +43,10 @@ void firstSuccessfulProviderWins() { var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, List.of( registration("env", - new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + new OrderingConstraint.Standard(StandardProvider.ENVIRONMENT), errorResolver("env")), registration("profile", - new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), + new OrderingConstraint.Standard(StandardProvider.SHARED_CONFIG), staticResolver("AK", "SK"))), null); IdentityResult result = chain.resolveIdentity(Context.empty()); @@ -60,10 +60,10 @@ void allFailReturnsAggregatedError() { var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, List.of( registration("env", - new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + new OrderingConstraint.Standard(StandardProvider.ENVIRONMENT), errorResolver("no env")), registration("profile", - new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), + new OrderingConstraint.Standard(StandardProvider.SHARED_CONFIG), errorResolver("no profile"))), null); IdentityResult result = chain.resolveIdentity(Context.empty()); @@ -79,10 +79,10 @@ void duplicateSlotThrows() { () -> CredentialChain.assemble(AwsCredentialsIdentity.class, List.of( registration("a", - new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + new OrderingConstraint.Standard(StandardProvider.ENVIRONMENT), errorResolver("a")), registration("b", - new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + new OrderingConstraint.Standard(StandardProvider.ENVIRONMENT), errorResolver("b"))), null)); } @@ -92,13 +92,13 @@ void relativeAfterInsertsCorrectly() { var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, List.of( registration("env", - new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + new OrderingConstraint.Standard(StandardProvider.ENVIRONMENT), errorResolver("env")), registration("profile", - new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), + new OrderingConstraint.Standard(StandardProvider.SHARED_CONFIG), errorResolver("profile")), registration("custom", - new OrderingConstraint.After(BuiltinProvider.ENVIRONMENT), + new OrderingConstraint.After(StandardProvider.ENVIRONMENT), errorResolver("custom"))), null); @@ -110,13 +110,13 @@ void relativeBeforeInsertsCorrectly() { var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, List.of( registration("env", - new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + new OrderingConstraint.Standard(StandardProvider.ENVIRONMENT), errorResolver("env")), registration("profile", - new OrderingConstraint.Builtin(BuiltinProvider.SHARED_CONFIG), + new OrderingConstraint.Standard(StandardProvider.SHARED_CONFIG), errorResolver("profile")), registration("custom", - new OrderingConstraint.Before(BuiltinProvider.SHARED_CONFIG), + new OrderingConstraint.Before(StandardProvider.SHARED_CONFIG), errorResolver("custom"))), null); @@ -128,10 +128,10 @@ void relativeToUnclaimedSlotAppendsAtEnd() { var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, List.of( registration("env", - new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + new OrderingConstraint.Standard(StandardProvider.ENVIRONMENT), errorResolver("env")), registration("custom", - new OrderingConstraint.After(BuiltinProvider.EC2_INSTANCE_METADATA), + new OrderingConstraint.After(StandardProvider.EC2_INSTANCE_METADATA), errorResolver("custom"))), null); @@ -144,10 +144,10 @@ void duplicateNameThrows() { () -> CredentialChain.assemble(AwsCredentialsIdentity.class, List.of( registration("env", - new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT), + new OrderingConstraint.Standard(StandardProvider.ENVIRONMENT), errorResolver("env")), registration("env", - new OrderingConstraint.Builtin(BuiltinProvider.JAVA_SYSTEM_PROPERTIES), + new OrderingConstraint.Standard(StandardProvider.JAVA_SYSTEM_PROPERTIES), errorResolver("env2"))), null)); } diff --git a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java index 2ee15b0ba..e1ee7e96e 100644 --- a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java +++ b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java @@ -27,7 +27,7 @@ void successfulProviderEmitsFeatureId() { var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, List.of( provider("env", - BuiltinProvider.ENVIRONMENT, + StandardProvider.ENVIRONMENT, Set.of(new CredentialFeatureId("g")), staticResolver("AK", "SK"))), null); @@ -47,11 +47,11 @@ void failedProviderDoesNotEmitFeatureId() { var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, List.of( provider("env", - BuiltinProvider.ENVIRONMENT, + StandardProvider.ENVIRONMENT, Set.of(new CredentialFeatureId("g")), errorResolver("no creds")), provider("profile", - BuiltinProvider.SHARED_CONFIG, + StandardProvider.SHARED_CONFIG, Set.of(new CredentialFeatureId("n")), staticResolver("AK", "SK"))), null); @@ -71,7 +71,7 @@ void multipleFeatureIdsEmitted() { var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, List.of( provider("proc", - BuiltinProvider.SHARED_CONFIG, + StandardProvider.SHARED_CONFIG, Set.of(new CredentialFeatureId("v"), new CredentialFeatureId("w")), staticResolver("AK", "SK"))), null); @@ -96,7 +96,7 @@ void noFeatureIdsWhenContextKeyNotSet() { var chain = CredentialChain.assemble(AwsCredentialsIdentity.class, List.of( provider("env", - BuiltinProvider.ENVIRONMENT, + StandardProvider.ENVIRONMENT, Set.of(new CredentialFeatureId("g")), staticResolver("AK", "SK"))), null); @@ -109,7 +109,7 @@ void noFeatureIdsWhenContextKeyNotSet() { private static ChainIdentityProvider provider( String name, - BuiltinProvider slot, + StandardProvider slot, Set featureIds, IdentityResolver resolver ) { @@ -126,7 +126,7 @@ public Set featureIds() { @Override public OrderingConstraint ordering() { - return new OrderingConstraint.Builtin(slot); + return new OrderingConstraint.Standard(slot); } @Override diff --git a/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java b/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java index 62c4de2db..089f7b11b 100644 --- a/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java +++ b/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java @@ -19,11 +19,11 @@ import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsResolver; import software.amazon.smithy.java.aws.config.AwsProfile; import software.amazon.smithy.java.aws.config.AwsProfileFile; -import software.amazon.smithy.java.aws.credentials.chain.BuiltinProvider; import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; +import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; import software.amazon.smithy.java.context.Context; import software.amazon.smithy.java.core.serde.document.Document; import software.amazon.smithy.java.json.JsonCodec; @@ -32,7 +32,7 @@ /** * Credential provider that fetches credentials from the EC2 Instance Metadata Service (IMDS). * - *

      Registers in the {@link BuiltinProvider#EC2_INSTANCE_METADATA} chain slot. Uses IMDSv2 exclusively + *

      Registers in the {@link StandardProvider#EC2_INSTANCE_METADATA} chain slot. Uses IMDSv2 exclusively * (no v1 fallback). Credentials are cached with static stability enabled per the AWS Static Stability SEP. */ public final class ImdsCredentialProvider implements ChainIdentityProvider { @@ -55,7 +55,7 @@ public Set featureIds() { @Override public OrderingConstraint ordering() { - return new OrderingConstraint.Builtin(BuiltinProvider.EC2_INSTANCE_METADATA); + return new OrderingConstraint.Standard(StandardProvider.EC2_INSTANCE_METADATA); } @Override diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java index ecc963e5b..4cbfa23f3 100644 --- a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java @@ -9,11 +9,11 @@ import software.amazon.smithy.java.auth.api.identity.Identity; import software.amazon.smithy.java.auth.api.identity.IdentityResolver; import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; -import software.amazon.smithy.java.aws.credentials.chain.BuiltinProvider; import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; +import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; public final class EnvironmentCredentialProvider implements ChainIdentityProvider { @@ -31,7 +31,7 @@ public Set featureIds() { @Override public OrderingConstraint ordering() { - return new OrderingConstraint.Builtin(BuiltinProvider.ENVIRONMENT); + return new OrderingConstraint.Standard(StandardProvider.ENVIRONMENT); } @Override diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java index 320211e65..560fbbae5 100644 --- a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java @@ -9,11 +9,11 @@ import software.amazon.smithy.java.auth.api.identity.Identity; import software.amazon.smithy.java.auth.api.identity.IdentityResolver; import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; -import software.amazon.smithy.java.aws.credentials.chain.BuiltinProvider; import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; +import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; public final class SystemPropertiesCredentialProvider implements ChainIdentityProvider { @@ -31,7 +31,7 @@ public Set featureIds() { @Override public OrderingConstraint ordering() { - return new OrderingConstraint.Builtin(BuiltinProvider.JAVA_SYSTEM_PROPERTIES); + return new OrderingConstraint.Standard(StandardProvider.JAVA_SYSTEM_PROPERTIES); } @Override From 4ce370859bdcaa3c2a0ff332bb6358c902b6f390 Mon Sep 17 00:00:00 2001 From: Michael Dowling Date: Wed, 13 May 2026 15:31:34 -0500 Subject: [PATCH 11/13] Move config into chain to simplify Reduces chain to a single, flat chain of fixed slots, including config based credentials. --- aws/aws-config/README.md | 19 +- aws/aws-config/build.gradle.kts | 6 +- .../aws/config/AwsConfigCredentialSource.java | 1 - .../AwsConfigCredentialSourceHandler.java | 65 - .../java/aws/config/AwsConfigFileType.java | 2 +- .../java/aws/config/AwsProfileFile.java | 28 + .../aws/config/ProfileCredentialProvider.java | 42 - .../aws/config/ProfileIdentityResolver.java | 347 ---- .../java/aws/config/SessionKeysHandler.java | 45 - .../java/aws/config/StaticKeysHandler.java | 45 - ...ws.config.AwsConfigCredentialSourceHandler | 3 - ...ws.credentials.chain.ChainIdentityProvider | 1 - .../AwsProfileCredentialsResolverTest.java | 273 --- aws/aws-credential-chain/build.gradle.kts | 3 + .../chain/ChainIdentityProvider.java | 16 +- .../aws/credentials/chain/CreateResult.java | 48 + .../credentials/chain/CredentialChain.java | 64 +- .../credentials/chain/ProviderContext.java | 105 +- .../credentials/chain/StandardProvider.java | 151 +- .../config/CredentialProcessHandler.java | 112 +- .../chain/config/SessionKeysHandler.java | 98 + .../chain/config/SharedConfigProvider.java | 46 + .../chain/config/StaticKeysHandler.java | 99 ++ ...ws.credentials.chain.ChainIdentityProvider | 4 + .../chain/AwsCredentialChainTest.java | 4 +- .../aws/credentials/chain/FeatureIdTest.java | 4 +- .../config/CredentialProcessHandlerTest.java | 72 +- .../config/config-file-location-tests.json | 135 ++ .../config/config-file-parser-tests.json | 1572 +++++++++++++++++ aws/aws-credentials-imds/build.gradle.kts | 1 - .../imds/ImdsCredentialProvider.java | 14 +- .../EnvironmentCredentialProvider.java | 9 +- .../SystemPropertiesCredentialProvider.java | 8 +- settings.gradle.kts | 2 +- 34 files changed, 2464 insertions(+), 980 deletions(-) delete mode 100644 aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsConfigCredentialSourceHandler.java delete mode 100644 aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/ProfileCredentialProvider.java delete mode 100644 aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/ProfileIdentityResolver.java delete mode 100644 aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/SessionKeysHandler.java delete mode 100644 aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/StaticKeysHandler.java delete mode 100644 aws/aws-config/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.config.AwsConfigCredentialSourceHandler delete mode 100644 aws/aws-config/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider delete mode 100644 aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/AwsProfileCredentialsResolverTest.java create mode 100644 aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CreateResult.java rename aws/{aws-config/src/main/java/software/amazon/smithy/java/aws => aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain}/config/CredentialProcessHandler.java (55%) create mode 100644 aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SessionKeysHandler.java create mode 100644 aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SharedConfigProvider.java create mode 100644 aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/StaticKeysHandler.java create mode 100644 aws/aws-credential-chain/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider rename aws/{aws-config/src/test/java/software/amazon/smithy/java/aws => aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain}/config/CredentialProcessHandlerTest.java (68%) create mode 100644 aws/aws-credential-chain/src/test/resources/software/amazon/smithy/java/aws/credentials/chain/config/config-file-location-tests.json create mode 100644 aws/aws-credential-chain/src/test/resources/software/amazon/smithy/java/aws/credentials/chain/config/config-file-parser-tests.json diff --git a/aws/aws-config/README.md b/aws/aws-config/README.md index 00e1c3383..ede16347f 100644 --- a/aws/aws-config/README.md +++ b/aws/aws-config/README.md @@ -1,6 +1,6 @@ # AWS Config -Parses AWS shared configuration files (`~/.aws/config` and `~/.aws/credentials`) and resolves credentials from profiles. +Provides credential resolution from AWS shared configuration files (`~/.aws/config` and `~/.aws/credentials`). Ships handlers for static keys, session keys, and credential_process. ## Dependency @@ -12,17 +12,13 @@ dependencies { ## Usage -Config file-based credential resolution is wired up automatically when -`AwsCredentialChainPlugin` is installed on a client. This module registers -itself in the `SHARED_CONFIG` chain slot via ServiceLoader, so no additional -code is needed beyond adding the dependency. +Config file-based credential resolution is wired up automatically when `AwsCredentialChainPlugin` is installed on a client. This module registers its `ChainIdentityProvider` implementations via ServiceLoader, so no additional code is needed beyond adding the dependency. ## Programmatic config file support You can load and query config files directly: ```java -// Load and query the config file AwsProfileFile file = AwsProfileFile.load(); AwsProfile profile = file.profile("default"); String region = profile.property("region"); @@ -31,13 +27,12 @@ String region = profile.property("region"); var resolver = ProfileIdentityResolver.builder(AwsCredentialsIdentity.class) .profileName("dev") .build(); - IdentityResult result = resolver.resolveIdentity(Context.empty()); ``` ## Supported credential sources -Profiles can define credentials in multiple ways. This module handles: +This module handles the following profile credential sources via `ChainIdentityProvider.resolve()`: - **Static keys** — `aws_access_key_id` + `aws_secret_access_key` - **Session keys** — static keys + `aws_session_token` @@ -45,12 +40,8 @@ Profiles can define credentials in multiple ways. This module handles: ## Modular credential sources -Additional sources like IMDS, AssumeRole, SSO, WebIdentity, and Login are -detected and typed but require separate handler modules (`aws-credentials-sts`, -`aws-credentials-sso`, etc.) to resolve. When possible, this module detects -if you intended to use one of these providers but are missing the dependency. +Additional sources (AssumeRole, SSO, WebIdentity, Login) are detected and typed by the chain module but require separate provider modules (`aws-credentials-sts`, `aws-credentials-sso`, etc.) to resolve. When a source is detected but no provider claims it, an actionable error names the missing dependency. ## Extensibility -Implement `AwsConfigCredentialSourceHandler` and register via -`META-INF/services` to handle additional source types. +Implement `ChainIdentityProvider.resolve()` in a new module and register via `META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider` to handle additional config-file source types. diff --git a/aws/aws-config/build.gradle.kts b/aws/aws-config/build.gradle.kts index b8b8e258f..d0c0af27d 100644 --- a/aws/aws-config/build.gradle.kts +++ b/aws/aws-config/build.gradle.kts @@ -4,17 +4,13 @@ plugins { } description = "This module provides parsing of AWS shared config and credentials files " + - "(~/.aws/config, ~/.aws/credentials) and an AwsCredentialsResolver backed by them." + "(~/.aws/config, ~/.aws/credentials) and the profile data model." extra["displayName"] = "Smithy :: Java :: AWS :: Config" extra["moduleName"] = "software.amazon.smithy.java.aws.config" dependencies { api(project(":aws:aws-auth-api")) - api(project(":auth-api")) implementation(project(":logging")) - implementation(project(":client:client-core")) - 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/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 index 50ae07eaf..ce326c47a 100644 --- 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 @@ -17,7 +17,6 @@ * 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. 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 deleted file mode 100644 index 9282738f5..000000000 --- a/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/AwsConfigCredentialSourceHandler.java +++ /dev/null @@ -1,65 +0,0 @@ -/* - * 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.Set; -import software.amazon.smithy.java.auth.api.identity.Identity; -import software.amazon.smithy.java.auth.api.identity.IdentityResult; -import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; -import software.amazon.smithy.java.context.Context; - -/** - * Strategy for turning an {@link AwsConfigCredentialSource} into an {@link Identity}. - * - *

      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 parameterized by identity type. For example, an SSO handler that exchanges a token for - * AWS credentials implements {@code AwsConfigCredentialSourceHandler}, while one - * that returns the token directly for bearer auth implements {@code AwsConfigCredentialSourceHandler}. - * - *

      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 - * filters handlers by {@link #identityType()} and, for each source, tries matching handlers until one returns non-null. - * - * @param the identity type this handler produces. - */ -public interface AwsConfigCredentialSourceHandler { - /** - * The identity type this handler resolves. - * - * @return the identity class (e.g., {@code AwsCredentialsIdentity.class} or {@code TokenIdentity.class}). - */ - Class identityType(); - - /** - * 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); - - /** - * The business metric feature IDs emitted when this handler successfully resolves an identity. - * - * @return the feature IDs, or empty if none. - */ - default Set featureIds() { - return Set.of(); - } - - /** - * Information passed from the enclosing resolver to each handler invocation. - * - * @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 index f629eb36f..a4a2b9468 100644 --- 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 @@ -8,7 +8,7 @@ /** * Identifies which of the two AWS shared configuration files is being parsed. */ -public enum AwsConfigFileType { +enum AwsConfigFileType { /** The configuration file (e.g., {@code ~/.aws/config}). */ CONFIGURATION, 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 index f68a78fe5..31dc49a3a 100644 --- 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 @@ -99,6 +99,34 @@ public static AwsProfileFile load() { return builder().build(); } + /** + * Loads the config/credentials files, returning {@code null} if neither exists or parsing fails. + */ + public static AwsProfileFile loadSilently() { + try { + return load(); + } catch (Exception e) { + return null; + } + } + + /** + * Returns the active profile based on {@code AWS_PROFILE} env var, {@code aws.profile} system property, + * or {@code "default"}. + * + * @return the active profile, or {@code null} if not found. + */ + public AwsProfile activeProfile() { + String name = System.getProperty("aws.profile"); + if (name == null) { + name = System.getenv("AWS_PROFILE"); + } + if (name == null) { + name = "default"; + } + return profile(name); + } + /** * @return a new builder. */ 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 deleted file mode 100644 index 655702b1e..000000000 --- a/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/ProfileCredentialProvider.java +++ /dev/null @@ -1,42 +0,0 @@ -/* - * 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.Identity; -import software.amazon.smithy.java.auth.api.identity.IdentityResolver; -import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; -import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; -import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; -import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; -import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; - -/** - * Registers {@link ProfileIdentityResolver} in the credential chain's - * {@link StandardProvider#SHARED_CONFIG} slot. - */ -public final class ProfileCredentialProvider implements ChainIdentityProvider { - @Override - public String name() { - return "SharedConfig"; - } - - @Override - public OrderingConstraint ordering() { - return new OrderingConstraint.Standard(StandardProvider.SHARED_CONFIG); - } - - @Override - @SuppressWarnings("unchecked") - public IdentityResolver create(Class identityType, ProviderContext context) { - if (identityType != AwsCredentialsIdentity.class) { - return null; - } - var resolver = ProfileIdentityResolver.builder(AwsCredentialsIdentity.class).build(); - context.properties().put(AwsProfileFile.CONTEXT_KEY, resolver.profileFile()); - return (IdentityResolver) CachingIdentityResolver.builder(resolver).executor(context.executor()).build(); - } -} diff --git a/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/ProfileIdentityResolver.java b/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/ProfileIdentityResolver.java deleted file mode 100644 index 55577ae6e..000000000 --- a/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/ProfileIdentityResolver.java +++ /dev/null @@ -1,347 +0,0 @@ -/* - * 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.Identity; -import software.amazon.smithy.java.auth.api.identity.IdentityResolver; -import software.amazon.smithy.java.auth.api.identity.IdentityResult; -import software.amazon.smithy.java.aws.config.AwsConfigCredentialSourceHandler.ResolutionContext; -import software.amazon.smithy.java.client.core.CallContext; -import software.amazon.smithy.java.context.Context; - -/** - * An {@link IdentityResolver} that reads credentials from a profile in the AWS shared configuration / credentials - * files by dispatching to a chain of {@link AwsConfigCredentialSourceHandler}s. - * - *

      Architecture

      - * - *

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

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

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

      Profile name selection

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

      {@link #refresh()} mutates the underlying {@link AwsProfileFile} in place (via - * {@link AwsProfileFile#refresh()}). Concurrent callers of {@link #resolveIdentity(Context)} - * observe the new state atomically after refresh completes. - */ -public final class ProfileIdentityResolver implements IdentityResolver { - - /** 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 Class identityType; - 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 ProfileIdentityResolver(Builder b) { - this.identityType = b.identityType; - this.profileName = b.profileName != null ? b.profileName : resolveDefaultProfileName(); - this.handlers = b.handlers.isEmpty() ? discoverHandlers(b.identityType) : 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( - Class identityType - ) { - List> found = new ArrayList<>(); - for (AwsConfigCredentialSourceHandler h : ServiceLoader.load(AwsConfigCredentialSourceHandler.class)) { - if (h.identityType() == identityType) { - @SuppressWarnings("unchecked") - var typed = (AwsConfigCredentialSourceHandler) h; - found.add(typed); - } - } - 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(Class identityType) { - return new Builder<>(identityType); - } - - /** - * @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 Class identityType() { - return identityType; - } - - @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.getFirst().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 (var handler : handlers) { - IdentityResult attempt = handler.tryResolve(source, ctx); - if (attempt != null) { - if (!handler.featureIds().isEmpty()) { - var ids = ctx.requestProperties().get(CallContext.FEATURE_IDS); - if (ids != null) { - ids.addAll(handler.featureIds()); - } - } - 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; - } - - public static final class Builder { - private final Class identityType; - 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(Class identityType) { - this.identityType = identityType; - } - - /** - * 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 preloaded {@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 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 ProfileIdentityResolver build() { - return new ProfileIdentityResolver<>(this); - } - } -} 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 deleted file mode 100644 index 7b1abd461..000000000 --- a/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/SessionKeysHandler.java +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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.Set; -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.credentials.chain.CredentialFeatureId; - -/** - * Handles {@link AwsConfigCredentialSource.SessionKeys}. - */ -public final class SessionKeysHandler implements AwsConfigCredentialSourceHandler { - - public SessionKeysHandler() {} - - private static final Set FEATURE_IDS = Set.of(new CredentialFeatureId("n")); - - @Override - public Class identityType() { - return AwsCredentialsIdentity.class; - } - - public Set featureIds() { - return FEATURE_IDS; - } - - @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 deleted file mode 100644 index 3e9f4503c..000000000 --- a/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/StaticKeysHandler.java +++ /dev/null @@ -1,45 +0,0 @@ -/* - * 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.Set; -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.credentials.chain.CredentialFeatureId; - -/** - * Handles {@link AwsConfigCredentialSource.StaticKeys}. - */ -public final class StaticKeysHandler implements AwsConfigCredentialSourceHandler { - - public StaticKeysHandler() {} - - private static final Set FEATURE_IDS = Set.of(new CredentialFeatureId("n")); - - @Override - public Class identityType() { - return AwsCredentialsIdentity.class; - } - - public Set featureIds() { - return FEATURE_IDS; - } - - @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 deleted file mode 100644 index 09adcae3f..000000000 --- a/aws/aws-config/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.config.AwsConfigCredentialSourceHandler +++ /dev/null @@ -1,3 +0,0 @@ -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.ChainIdentityProvider b/aws/aws-config/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider deleted file mode 100644 index 5394a60a0..000000000 --- a/aws/aws-config/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider +++ /dev/null @@ -1 +0,0 @@ -software.amazon.smithy.java.aws.config.ProfileCredentialProvider 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 deleted file mode 100644 index 5ea8e44ff..000000000 --- a/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/AwsProfileCredentialsResolverTest.java +++ /dev/null @@ -1,273 +0,0 @@ -/* - * 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); - - var resolver = ProfileIdentityResolver.builder(AwsCredentialsIdentity.class) - .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(ProfileIdentityResolver.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 = - new AwsConfigCredentialSourceHandler<>() { - @Override - public Class identityType() { - return AwsCredentialsIdentity.class; - } - - @Override - public IdentityResult tryResolve( - AwsConfigCredentialSource source, - ResolutionContext ctx - ) { - if (!(source instanceof AwsConfigCredentialSource.AssumeRole r)) { - return null; - } - return IdentityResult.of(AwsCredentialsIdentity.create( - "assumed-" + r.roleArn(), - "assumed-secret")); - } - }; - - var resolver = ProfileIdentityResolver.builder(AwsCredentialsIdentity.class) - .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); - - var resolver = ProfileIdentityResolver.builder(AwsCredentialsIdentity.class) - .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); - - var resolver = ProfileIdentityResolver.builder(AwsCredentialsIdentity.class) - .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); - - var resolver = ProfileIdentityResolver.builder(AwsCredentialsIdentity.class) - .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 - """); - - var 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(); - - var resolver = ProfileIdentityResolver.builder(AwsCredentialsIdentity.class) - .profileFile(file) - .profileName("prod") - .build(); - - assertEquals("PK", resolver.resolveIdentity(Context.empty()).unwrap().accessKeyId()); - } - - private static ProfileIdentityResolver buildResolver(Path credentials, String profile) { - return ProfileIdentityResolver.builder(AwsCredentialsIdentity.class) - .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-credential-chain/build.gradle.kts b/aws/aws-credential-chain/build.gradle.kts index 774920a37..acb06177f 100644 --- a/aws/aws-credential-chain/build.gradle.kts +++ b/aws/aws-credential-chain/build.gradle.kts @@ -10,6 +10,9 @@ extra["moduleName"] = "software.amazon.smithy.java.aws.credentials.chain" dependencies { api(project(":aws:aws-auth-api")) api(project(":auth-api")) + api(project(":aws:aws-config")) implementation(project(":client:client-core")) + implementation(project(":codecs:json-codec", configuration = "shadow")) implementation(project(":logging")) + testImplementation("tools.jackson.core:jackson-databind:3.1.2") } diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainIdentityProvider.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainIdentityProvider.java index a85efa87d..57686c304 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainIdentityProvider.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainIdentityProvider.java @@ -7,7 +7,6 @@ import java.util.Set; import software.amazon.smithy.java.auth.api.identity.Identity; -import software.amazon.smithy.java.auth.api.identity.IdentityResolver; /** * SPI for registering an identity provider into a credential/token chain. @@ -38,13 +37,20 @@ default Set featureIds() { /** * Create the identity resolver for the requested identity type. * - *

      Called once during chain assembly. If this provider does not support the requested identity - * type, it MUST return {@code null} and the chain will skip it. + *

      Called once during chain assembly in slot order. Return: + *

        + *
      • {@link CreateResult#pass()} — this provider does not participate
      • + *
      • {@link CreateResult.PossibleMatch} — resolver added, assembly continues
      • + *
      • {@link CreateResult.UnconditionalMatch} — resolver added, assembly stops
      • + *
      + * + *

      Providers that need AWS config file data should read it from + * {@link ProviderContext#profile()}, populated by the {@code SHARED_CONFIG} provider. * * @param identityType the identity class the chain is resolving. * @param context shared resources provided by the chain. * @param the identity type. - * @return the resolver, or {@code null} if this provider does not support the requested type. + * @return the create result. */ - IdentityResolver create(Class identityType, ProviderContext context); + CreateResult create(Class identityType, ProviderContext context); } diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CreateResult.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CreateResult.java new file mode 100644 index 000000000..08b7c73e1 --- /dev/null +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CreateResult.java @@ -0,0 +1,48 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.chain; + +import software.amazon.smithy.java.auth.api.identity.Identity; +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; + +/** + * The result of {@link ChainIdentityProvider#create}. Indicates whether the provider + * participates in the chain and whether it is unconditionally authoritative. + * + * @param the identity type. + */ +public sealed interface CreateResult { + /** + * This provider does not participate in the chain (preconditions not met). + */ + record Pass() implements CreateResult {} + + /** + * This provider might resolve credentials, but resolution could fail at request time + * (e.g., IMDS network call, STS assume-role). Assembly continues to discover fallback providers. + * + * @param resolver the resolver to add to the chain. + */ + record PossibleMatch(IdentityResolver resolver) implements CreateResult {} + + /** + * This provider will unconditionally resolve credentials (e.g., environment variables are set, + * static keys are present). Assembly stops — no further providers are needed. + * + * @param resolver the resolver to add to the chain. + */ + record UnconditionalMatch(IdentityResolver resolver) implements CreateResult {} + + /** + * Returns a {@link Pass} result. + */ + @SuppressWarnings("unchecked") + static CreateResult pass() { + return (CreateResult) PASS; + } + + Pass PASS = new Pass<>(); +} diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialChain.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialChain.java index 5a8673fc2..08ad89e0e 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialChain.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialChain.java @@ -125,7 +125,8 @@ static CredentialChain assemble( } // Use a single executor for each provider (used for caching). - ProviderContext ctx = new ProviderContext(executor, Context.create()); + // Parse config files and resolve active profile before calling providers. + ProviderContext ctx = new ProviderContext(executor); // Precompute insert positions: for each slot, how many claimed slots come before it // and up to and including it. This avoids re-scanning the enum on every relative insert. @@ -142,32 +143,55 @@ static CredentialChain assemble( // Build the ordered list: standard slots in enum order. List> ordered = new ArrayList<>(); + boolean done = false; for (StandardProvider slot : StandardProvider.values()) { + if (done) { + break; + } ChainIdentityProvider r = standards.get(slot); if (r != null) { - IdentityResolver resolver = r.create(identityType, ctx); - if (resolver != null) { - ordered.add(new NamedResolver<>(r.name(), r.featureIds(), resolver)); + CreateResult result = r.create(identityType, ctx); + switch (result) { + case CreateResult.UnconditionalMatch(var resolver) -> { + ordered.add(new NamedResolver<>(r.name(), r.featureIds(), resolver)); + done = true; + } + case CreateResult.PossibleMatch(var resolver) -> + ordered.add(new NamedResolver<>(r.name(), r.featureIds(), resolver)); + case CreateResult.Pass ignored -> { + } } } } - // Insert relative providers using precomputed positions. - for (ChainIdentityProvider r : relatives) { - int insertAt; - if (r.ordering() instanceof OrderingConstraint.After(StandardProvider slot)) { - insertAt = insertAfter.get(slot); - } else if (r.ordering() instanceof OrderingConstraint.Before(StandardProvider slot)) { - insertAt = insertBefore.get(slot); - } else { - insertAt = ordered.size(); - } - if (insertAt > ordered.size()) { - insertAt = ordered.size(); - } - IdentityResolver relResolver = r.create(identityType, ctx); - if (relResolver != null) { - ordered.add(insertAt, new NamedResolver<>(r.name(), r.featureIds(), relResolver)); + // Insert relative providers using precomputed positions (only if assembly wasn't short-circuited). + if (!done) { + for (ChainIdentityProvider r : relatives) { + int insertAt; + if (r.ordering() instanceof OrderingConstraint.After(StandardProvider slot)) { + insertAt = insertAfter.get(slot); + } else if (r.ordering() instanceof OrderingConstraint.Before(StandardProvider slot)) { + insertAt = insertBefore.get(slot); + } else { + insertAt = ordered.size(); + } + if (insertAt > ordered.size()) { + insertAt = ordered.size(); + } + CreateResult result = r.create(identityType, ctx); + switch (result) { + case CreateResult.UnconditionalMatch(var resolver) -> { + ordered.add(insertAt, new NamedResolver<>(r.name(), r.featureIds(), resolver)); + done = true; + } + case CreateResult.PossibleMatch(var resolver) -> + ordered.add(insertAt, new NamedResolver<>(r.name(), r.featureIds(), resolver)); + case CreateResult.Pass ignored -> { + } + } + if (done) { + break; + } } } diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ProviderContext.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ProviderContext.java index ed573838c..3a31c1362 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ProviderContext.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ProviderContext.java @@ -6,12 +6,109 @@ package software.amazon.smithy.java.aws.credentials.chain; import java.util.concurrent.ScheduledExecutorService; +import software.amazon.smithy.java.aws.config.AwsProfile; +import software.amazon.smithy.java.aws.config.AwsProfileFile; import software.amazon.smithy.java.context.Context; /** - * Context passed to {@link ChainIdentityProvider#create)} during chain assembly. + * Shared context passed to each {@link ChainIdentityProvider#create} during chain assembly. * - * @param executor shared executor for background refresh. - * @param properties shared property bag for cross-provider data. + *

      Providers earlier in the chain populate this context for downstream providers. In particular, + * the {@code SHARED_CONFIG} provider parses the AWS config/credentials files and calls + * {@link #setProfileFile} and {@link #setProfile} before any profile-based providers run. + * + *

      This class is mutable. Providers may read and write to it during assembly; the context + * is also accessible at request time by resolvers that close over it (e.g., for live reload + * after {@code invalidate()}). */ -public record ProviderContext(ScheduledExecutorService executor, Context properties) {} +public final class ProviderContext { + private final ScheduledExecutorService executor; + private final Context properties; + private final String profileNameOverride; + private AwsProfileFile profileFile; + private AwsProfile profile; + + public ProviderContext(ScheduledExecutorService executor) { + this(executor, null); + } + + public ProviderContext(ScheduledExecutorService executor, String profileNameOverride) { + this.executor = executor; + this.properties = Context.create(); + this.profileNameOverride = profileNameOverride; + } + + /** + * Returns the shared executor for scheduling background credential refresh. + * + * @return the executor, or {@code null} if none was provided. + */ + public ScheduledExecutorService executor() { + return executor; + } + + /** + * Returns the client-specified profile name override, or {@code null} to use the + * default resolution order ({@code AWS_PROFILE} env var, {@code aws.profile} system + * property, then {@code "default"}). + * + * @return the profile name override, or {@code null}. + */ + public String profileNameOverride() { + return profileNameOverride; + } + + /** + * Returns a general-purpose typed property bag for sharing arbitrary state between providers. + * + * @return the property bag; never {@code null}. + */ + public Context properties() { + return properties; + } + + /** + * Returns the parsed AWS config/credentials file, or {@code null} if no config file was found. + * + *

      Populated by the {@code SHARED_CONFIG} provider at assembly time. May be refreshed + * in place after an {@code invalidate()} call. + * + * @return the profile file, or {@code null}. + */ + public AwsProfileFile profileFile() { + return profileFile; + } + + /** + * Sets the parsed profile file. Called by the {@code SHARED_CONFIG} provider during assembly + * and potentially during invalidation to refresh from disk. + * + * @param profileFile the parsed profile file. + */ + public void setProfileFile(AwsProfileFile profileFile) { + this.profileFile = profileFile; + } + + /** + * Returns the active AWS profile (resolved from {@code AWS_PROFILE} env var, + * {@code aws.profile} system property, or defaulting to {@code "default"}). + * + *

      Returns {@code null} if no config file was loaded or the active profile name + * does not exist in the file. + * + * @return the active profile, or {@code null}. + */ + public AwsProfile profile() { + return profile; + } + + /** + * Sets the active profile. Called by the {@code SHARED_CONFIG} provider during assembly + * and potentially during invalidation. + * + * @param profile the active profile. + */ + public void setProfile(AwsProfile profile) { + this.profile = profile; + } +} diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/StandardProvider.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/StandardProvider.java index 607c2da62..7e3e873f9 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/StandardProvider.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/StandardProvider.java @@ -19,7 +19,11 @@ * (via {@link #moduleSuggestion()}). */ public enum StandardProvider { - /** Credentials explicitly provided in code. */ + /** + * Credentials explicitly provided in code (e.g., passed to a client builder). + * + *

      No detection signal — this slot is only claimed when credentials are programmatically set. + */ CODE(null) { @Override public boolean isDetected() { @@ -27,7 +31,12 @@ public boolean isDetected() { } }, - /** Credentials from JVM system properties ({@code aws.accessKeyId}, etc.). */ + /** + * Credentials from JVM system properties. + * + *

      Detected when {@code aws.accessKeyId} is set. Skipped on platforms without + * language-level property systems. + */ JAVA_SYSTEM_PROPERTIES("software.amazon.smithy.java:aws-client-core") { @Override public boolean isDetected() { @@ -35,7 +44,10 @@ public boolean isDetected() { } }, - /** Credentials from environment variables ({@code AWS_ACCESS_KEY_ID}, etc.). */ + /** + * Credentials from environment variables ({@code AWS_ACCESS_KEY_ID}, + * {@code AWS_SECRET_ACCESS_KEY}). + */ ENVIRONMENT("software.amazon.smithy.java:aws-client-core") { @Override public boolean isDetected() { @@ -43,7 +55,12 @@ public boolean isDetected() { } }, - /** Web identity token from environment variables ({@code AWS_WEB_IDENTITY_TOKEN_FILE} + {@code AWS_ROLE_ARN}). */ + /** + * Web identity token from environment variables. + * + *

      Detected when both {@code AWS_WEB_IDENTITY_TOKEN_FILE} and {@code AWS_ROLE_ARN} + * are set. Requires an STS module to resolve. + */ WEB_IDENTITY_TOKEN_ENV("software.amazon.smithy.java:aws-credentials-sts") { @Override public boolean isDetected() { @@ -51,8 +68,15 @@ public boolean isDetected() { } }, - /** Credentials from the AWS shared config/credentials files. */ - SHARED_CONFIG("software.amazon.smithy.java:aws-config") { + /** + * Parses AWS shared config/credentials files and stores the result on the + * {@link ProviderContext} for downstream providers. + * + *

      This provider does not itself resolve credentials — it returns {@code null} from + * {@code create()}. Its purpose is to make the parsed profile available via + * {@link ProviderContext#profile()} for all subsequent profile-based slots. + */ + SHARED_CONFIG(null) { @Override public boolean isDetected() { var home = System.getProperty("user.home"); @@ -64,7 +88,113 @@ public boolean isDetected() { } }, - /** Credentials from an HTTP endpoint (ECS container, EKS pod identity, etc.). */ + /** + * Profile-based web identity token ({@code web_identity_token_file} + {@code role_arn}). + * + *

      Requires the STS module. Reads the active profile from {@link ProviderContext#profile()}. + */ + PROFILE_WEB_IDENTITY("software.amazon.smithy.java:aws-credentials-sts") { + @Override + public boolean isDetected() { + return false; + } + }, + + /** + * Profile-based assume role ({@code role_arn} with {@code source_profile} or + * {@code credential_source}). + * + *

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

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

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

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

      Invokes an external process on each resolution. The command string is captured at + * assembly time from the active profile. + */ + PROFILE_CREDENTIAL_PROCESS(null) { + @Override + public boolean isDetected() { + return false; + } + }, + + /** + * Profile-based session keys ({@code aws_access_key_id} + {@code aws_secret_access_key} + * + {@code aws_session_token}). + * + *

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

      Re-reads from the profile on each resolution to support live reload after invalidation. + */ + PROFILE_STATIC_KEYS(null) { + @Override + public boolean isDetected() { + return false; + } + }, + + /** + * Credentials from an HTTP endpoint (ECS container credentials, EKS pod identity). + * + *

      Detected when {@code AWS_CONTAINER_CREDENTIALS_FULL_URI} or + * {@code AWS_CONTAINER_CREDENTIALS_RELATIVE_URI} is set. + */ ECS_CONTAINER("software.amazon.smithy.java:aws-credentials-ecs") { @Override public boolean isDetected() { @@ -73,11 +203,14 @@ public boolean isDetected() { } }, - /** Credentials from EC2 instance metadata service (IMDS). */ + /** + * Credentials from the EC2 Instance Metadata Service (IMDSv2). + * + *

      No cheap detection signal — IMDS requires a network call. Always tried last. + */ EC2_INSTANCE_METADATA("software.amazon.smithy.java:aws-credentials-imds") { @Override public boolean isDetected() { - // No cheap signal; IMDS requires a network call to detect. return false; } }; diff --git a/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/CredentialProcessHandler.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandler.java similarity index 55% rename from aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/CredentialProcessHandler.java rename to aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandler.java index 2c1ac7a61..1aafd96ee 100644 --- a/aws/aws-config/src/main/java/software/amazon/smithy/java/aws/config/CredentialProcessHandler.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandler.java @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package software.amazon.smithy.java.aws.config; +package software.amazon.smithy.java.aws.credentials.chain.config; import java.io.IOException; import java.io.InputStream; @@ -14,98 +14,126 @@ import java.util.Locale; import java.util.Set; import java.util.concurrent.TimeUnit; +import software.amazon.smithy.java.auth.api.identity.Identity; +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; import software.amazon.smithy.java.auth.api.identity.IdentityResult; import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.config.AwsConfigCredentialSource; +import software.amazon.smithy.java.aws.config.AwsProfile; +import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; +import software.amazon.smithy.java.aws.credentials.chain.CreateResult; import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; +import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; +import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; +import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; +import software.amazon.smithy.java.context.Context; import software.amazon.smithy.java.core.serde.document.Document; import software.amazon.smithy.java.json.JsonCodec; import software.amazon.smithy.java.logging.InternalLogger; /** - * Handles {@link AwsConfigCredentialSource.CredentialProcess} by invoking an external process and parsing its JSON - * stdout per the - * credential_process specification. + * Resolves credentials by invoking an external process specified by {@code credential_process} + * in the active AWS profile. * - *

      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. + *

      The command string is captured at assembly time and does not change after construction. + * Each call to {@code resolveIdentity()} re-executes the process to obtain fresh credentials, + * so expiring credentials returned by the process are naturally refreshed without caching. */ -public final class CredentialProcessHandler implements AwsConfigCredentialSourceHandler { +public final class CredentialProcessHandler implements ChainIdentityProvider { private static final InternalLogger LOGGER = InternalLogger.getLogger(CredentialProcessHandler.class); private static final JsonCodec CODEC = JsonCodec.builder().build(); private static final long TIMEOUT_SECONDS = 60; private static final int MAX_OUTPUT_BYTES = 64000; - - public CredentialProcessHandler() {} - private static final Set FEATURE_IDS = Set.of( new CredentialFeatureId("v"), new CredentialFeatureId("w")); + private static final IdentityResult NO_PROFILE = + IdentityResult.ofError(CredentialProcessHandler.class, "No active profile"); + private static final IdentityResult NOT_FOUND = + IdentityResult.ofError(CredentialProcessHandler.class, "No credential_process in profile"); @Override - public Class identityType() { - return AwsCredentialsIdentity.class; + public String name() { + return "CredentialProcess"; } + @Override + public OrderingConstraint ordering() { + return new OrderingConstraint.Standard(StandardProvider.PROFILE_CREDENTIAL_PROCESS); + } + + @Override public Set featureIds() { return FEATURE_IDS; } @Override - public IdentityResult tryResolve( - AwsConfigCredentialSource source, - ResolutionContext context - ) { - if (!(source instanceof AwsConfigCredentialSource.CredentialProcess(String commandLine))) { - return null; + @SuppressWarnings("unchecked") + public CreateResult create(Class identityType, ProviderContext context) { + if (identityType != AwsCredentialsIdentity.class) { + return CreateResult.pass(); } - try { - return execute(commandLine); - } catch (IOException | InterruptedException e) { - return IdentityResult.ofError(getClass(), "credential_process failed: " + e.getMessage()); + AwsProfile profile = context.profile(); + if (profile == null) { + return CreateResult.pass(); } + + for (AwsConfigCredentialSource source : profile.credentialSources()) { + if (source instanceof AwsConfigCredentialSource.CredentialProcess(String commandLine)) { + return (CreateResult) new CreateResult.PossibleMatch<>(new Resolver(commandLine)); + } + } + + return CreateResult.pass(); } - private IdentityResult execute(String commandLine) + private record Resolver(String commandLine) implements IdentityResolver { + @Override + public Class identityType() { + return AwsCredentialsIdentity.class; + } + + @Override + public IdentityResult resolveIdentity(Context ctx) { + try { + return execute(commandLine); + } catch (IOException | InterruptedException e) { + return IdentityResult.ofError(CredentialProcessHandler.class, + "credential_process failed: " + e.getMessage()); + } + } + } + + private static IdentityResult execute(String commandLine) throws IOException, InterruptedException { List cmd = buildCommand(commandLine); Process process = new ProcessBuilder(cmd).redirectErrorStream(false).start(); - + byte[] buf = new byte[MAX_OUTPUT_BYTES + 1]; String stdout; String stderr; - // 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"); + return IdentityResult.ofError(CredentialProcessHandler.class, + "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 IdentityResult.ofError(CredentialProcessHandler.class, 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); @@ -113,7 +141,6 @@ private static List buildCommand(String 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) { @@ -122,19 +149,18 @@ private static String readLimited(InputStream in, byte[] buf) throws IOException return new String(buf, 0, n, StandardCharsets.UTF_8); } - private IdentityResult parseOutput(String json) { + private static IdentityResult parseOutput(String json) { Document doc = CODEC.createDeserializer(json.getBytes(StandardCharsets.UTF_8)).readDocument(); - Document versionNode = doc.getMember("Version"); if (versionNode != null && versionNode.asInteger() != 1) { - return IdentityResult.ofError(getClass(), + return IdentityResult.ofError(CredentialProcessHandler.class, "credential_process output has unsupported Version: " + versionNode.asInteger()); } String accessKeyId = stringMember(doc, "AccessKeyId"); String secretAccessKey = stringMember(doc, "SecretAccessKey"); if (accessKeyId == null || secretAccessKey == null) { - return IdentityResult.ofError(getClass(), + return IdentityResult.ofError(CredentialProcessHandler.class, "credential_process output missing required AccessKeyId or SecretAccessKey"); } diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SessionKeysHandler.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SessionKeysHandler.java new file mode 100644 index 000000000..65cdeaa37 --- /dev/null +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SessionKeysHandler.java @@ -0,0 +1,98 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.chain.config; + +import java.util.Set; +import software.amazon.smithy.java.auth.api.identity.Identity; +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; +import software.amazon.smithy.java.auth.api.identity.IdentityResult; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.config.AwsConfigCredentialSource; +import software.amazon.smithy.java.aws.config.AwsProfile; +import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; +import software.amazon.smithy.java.aws.credentials.chain.CreateResult; +import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; +import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; +import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; +import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; +import software.amazon.smithy.java.context.Context; + +/** + * Resolves {@link AwsConfigCredentialSource.SessionKeys} from the active profile. + * Re-reads the profile on each call to support live reload after invalidation. + */ +public final class SessionKeysHandler implements ChainIdentityProvider { + + private static final Set FEATURE_IDS = Set.of(new CredentialFeatureId("n")); + private static final IdentityResult NO_PROFILE = + IdentityResult.ofError(SessionKeysHandler.class, "No active profile"); + private static final IdentityResult NOT_FOUND = + IdentityResult.ofError(SessionKeysHandler.class, "No session keys in profile"); + + @Override + public String name() { + return "SessionKeys"; + } + + @Override + public OrderingConstraint ordering() { + return new OrderingConstraint.Standard(StandardProvider.PROFILE_SESSION_KEYS); + } + + @Override + public Set featureIds() { + return FEATURE_IDS; + } + + @Override + @SuppressWarnings("unchecked") + public CreateResult create(Class identityType, ProviderContext context) { + if (identityType != AwsCredentialsIdentity.class) { + return CreateResult.pass(); + } + + AwsProfile profile = context.profile(); + if (profile == null) { + return CreateResult.pass(); + } + + for (AwsConfigCredentialSource source : profile.credentialSources()) { + if (source instanceof AwsConfigCredentialSource.SessionKeys) { + return (CreateResult) new CreateResult.UnconditionalMatch<>(new Resolver(context)); + } + } + + return CreateResult.pass(); + } + + private record Resolver(ProviderContext context) implements IdentityResolver { + @Override + public Class identityType() { + return AwsCredentialsIdentity.class; + } + + @Override + public IdentityResult resolveIdentity(Context ctx) { + AwsProfile profile = context.profile(); + if (profile == null) { + return NO_PROFILE; + } + + for (AwsConfigCredentialSource source : profile.credentialSources()) { + if (source instanceof AwsConfigCredentialSource.SessionKeys(String accessKeyId, String secretAccessKey, String sessionToken, String accountId)) { + return IdentityResult.of(AwsCredentialsIdentity.create( + accessKeyId, + secretAccessKey, + sessionToken, + null, + accountId)); + } + } + + return NOT_FOUND; + } + } +} diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SharedConfigProvider.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SharedConfigProvider.java new file mode 100644 index 000000000..25ea90637 --- /dev/null +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SharedConfigProvider.java @@ -0,0 +1,46 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.chain.config; + +import software.amazon.smithy.java.auth.api.identity.Identity; +import software.amazon.smithy.java.aws.config.AwsProfileFile; +import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; +import software.amazon.smithy.java.aws.credentials.chain.CreateResult; +import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; +import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; +import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; + +/** + * Claims the {@link StandardProvider#SHARED_CONFIG} slot. Parses the AWS config/credentials + * files and stores the result on the {@link ProviderContext} for downstream providers. + * Returns {@code null} — it does not itself resolve credentials. + */ +public final class SharedConfigProvider implements ChainIdentityProvider { + @Override + public String name() { + return "SharedConfig"; + } + + @Override + public OrderingConstraint ordering() { + return new OrderingConstraint.Standard(StandardProvider.SHARED_CONFIG); + } + + @Override + public CreateResult create(Class identityType, ProviderContext context) { + AwsProfileFile profileFile = AwsProfileFile.loadSilently(); + if (profileFile != null) { + context.setProfileFile(profileFile); + String name = context.profileNameOverride(); + if (name == null) { + context.setProfile(profileFile.activeProfile()); + } else { + context.setProfile(profileFile.profile(name)); + } + } + return CreateResult.pass(); + } +} diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/StaticKeysHandler.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/StaticKeysHandler.java new file mode 100644 index 000000000..f163d3d5a --- /dev/null +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/StaticKeysHandler.java @@ -0,0 +1,99 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.chain.config; + +import java.util.Set; +import software.amazon.smithy.java.auth.api.identity.Identity; +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; +import software.amazon.smithy.java.auth.api.identity.IdentityResult; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.config.AwsConfigCredentialSource; +import software.amazon.smithy.java.aws.config.AwsProfile; +import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; +import software.amazon.smithy.java.aws.credentials.chain.CreateResult; +import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; +import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; +import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; +import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; +import software.amazon.smithy.java.context.Context; + +/** + * Resolves {@link AwsConfigCredentialSource.StaticKeys} from the active profile. + * Re-reads the profile on each call to support live reload after invalidation. + */ +public final class StaticKeysHandler implements ChainIdentityProvider { + + private static final Set FEATURE_IDS = Set.of(new CredentialFeatureId("n")); + private static final IdentityResult NO_PROFILE = + IdentityResult.ofError(StaticKeysHandler.class, "No active profile"); + private static final IdentityResult NOT_FOUND = + IdentityResult.ofError(StaticKeysHandler.class, "No static keys in profile"); + + @Override + public String name() { + return "StaticKeys"; + } + + @Override + public OrderingConstraint ordering() { + return new OrderingConstraint.Standard(StandardProvider.PROFILE_STATIC_KEYS); + } + + @Override + public Set featureIds() { + return FEATURE_IDS; + } + + @Override + @SuppressWarnings("unchecked") + public CreateResult create(Class identityType, ProviderContext context) { + if (identityType != AwsCredentialsIdentity.class) { + return CreateResult.pass(); + } + + AwsProfile profile = context.profile(); + if (profile == null) { + return CreateResult.pass(); + } + + // Check if this source type exists at assembly time + for (AwsConfigCredentialSource source : profile.credentialSources()) { + if (source instanceof AwsConfigCredentialSource.StaticKeys) { + return (CreateResult) new CreateResult.UnconditionalMatch<>(new Resolver(context)); + } + } + + return CreateResult.pass(); + } + + private record Resolver(ProviderContext context) implements IdentityResolver { + @Override + public Class identityType() { + return AwsCredentialsIdentity.class; + } + + @Override + public IdentityResult resolveIdentity(Context ctx) { + AwsProfile profile = context.profile(); + if (profile == null) { + return NO_PROFILE; + } + + for (AwsConfigCredentialSource source : profile.credentialSources()) { + if (source instanceof AwsConfigCredentialSource.StaticKeys(String accessKeyId, String secretAccessKey, String accountId)) { + return IdentityResult.of(AwsCredentialsIdentity.create( + accessKeyId, + secretAccessKey, + null, + null, + accountId)); + } + } + + return NOT_FOUND; + } + } +} diff --git a/aws/aws-credential-chain/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider b/aws/aws-credential-chain/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider new file mode 100644 index 000000000..f2a4dafcf --- /dev/null +++ b/aws/aws-credential-chain/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider @@ -0,0 +1,4 @@ +software.amazon.smithy.java.aws.credentials.chain.config.SharedConfigProvider +software.amazon.smithy.java.aws.credentials.chain.config.StaticKeysHandler +software.amazon.smithy.java.aws.credentials.chain.config.SessionKeysHandler +software.amazon.smithy.java.aws.credentials.chain.config.CredentialProcessHandler diff --git a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java index b867a1767..ebe685d1b 100644 --- a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java +++ b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java @@ -179,8 +179,8 @@ public OrderingConstraint ordering() { @Override @SuppressWarnings("unchecked") - public IdentityResolver create(Class identityType, ProviderContext context) { - return (IdentityResolver) resolver; + public CreateResult create(Class identityType, ProviderContext context) { + return (CreateResult) new CreateResult.PossibleMatch<>(resolver); } }; } diff --git a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java index e1ee7e96e..f64a1a6b4 100644 --- a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java +++ b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java @@ -131,8 +131,8 @@ public OrderingConstraint ordering() { @Override @SuppressWarnings("unchecked") - public IdentityResolver create(Class identityType, ProviderContext context) { - return (IdentityResolver) resolver; + public CreateResult create(Class identityType, ProviderContext context) { + return (CreateResult) new CreateResult.PossibleMatch<>(resolver); } }; } diff --git a/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/CredentialProcessHandlerTest.java b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandlerTest.java similarity index 68% rename from aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/CredentialProcessHandlerTest.java rename to aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandlerTest.java index 5222b2fb8..93f472880 100644 --- a/aws/aws-config/src/test/java/software/amazon/smithy/java/aws/config/CredentialProcessHandlerTest.java +++ b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandlerTest.java @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -package software.amazon.smithy.java.aws.config; +package software.amazon.smithy.java.aws.credentials.chain.config; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -16,9 +16,13 @@ import java.nio.file.Path; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import software.amazon.smithy.java.auth.api.identity.IdentityResolver; import software.amazon.smithy.java.auth.api.identity.IdentityResult; import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; -import software.amazon.smithy.java.aws.config.AwsConfigCredentialSourceHandler.ResolutionContext; +import software.amazon.smithy.java.aws.config.AwsConfigCredentialSource; +import software.amazon.smithy.java.aws.config.AwsProfileFile; +import software.amazon.smithy.java.aws.credentials.chain.CreateResult; +import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; import software.amazon.smithy.java.context.Context; class CredentialProcessHandlerTest { @@ -33,7 +37,7 @@ void successfulProcessReturnsCredentials(@TempDir Path tmp) throws IOException { AwsConfigCredentialSource.CredentialProcess source = new AwsConfigCredentialSource.CredentialProcess(script.toString()); - IdentityResult result = new CredentialProcessHandler().tryResolve(source, ctx()); + IdentityResult result = createFromProfileResult(source); assertNotNull(result); AwsCredentialsIdentity id = result.unwrap(); @@ -53,7 +57,7 @@ void processWithExpirationParsesTimestamp(@TempDir Path tmp) throws IOException AwsConfigCredentialSource.CredentialProcess source = new AwsConfigCredentialSource.CredentialProcess(script.toString()); - AwsCredentialsIdentity id = new CredentialProcessHandler().tryResolve(source, ctx()).unwrap(); + AwsCredentialsIdentity id = createFromProfileResult(source).unwrap(); assertNotNull(id.expirationTime()); assertEquals("2099-01-01T00:00:00Z", id.expirationTime().toString()); } @@ -67,7 +71,7 @@ void processWithoutSessionTokenReturnsBasicCredentials(@TempDir Path tmp) throws AwsConfigCredentialSource.CredentialProcess source = new AwsConfigCredentialSource.CredentialProcess(script.toString()); - AwsCredentialsIdentity id = new CredentialProcessHandler().tryResolve(source, ctx()).unwrap(); + AwsCredentialsIdentity id = createFromProfileResult(source).unwrap(); assertEquals("AK", id.accessKeyId()); assertEquals("SK", id.secretAccessKey()); assertNull(id.sessionToken()); @@ -83,7 +87,7 @@ void nonZeroExitCodeReturnsError(@TempDir Path tmp) throws IOException { AwsConfigCredentialSource.CredentialProcess source = new AwsConfigCredentialSource.CredentialProcess(script.toString()); - IdentityResult result = new CredentialProcessHandler().tryResolve(source, ctx()); + IdentityResult result = createFromProfileResult(source); assertNotNull(result); assertNull(result.identity()); @@ -99,7 +103,7 @@ void missingRequiredFieldsReturnsError(@TempDir Path tmp) throws IOException { AwsConfigCredentialSource.CredentialProcess source = new AwsConfigCredentialSource.CredentialProcess(script.toString()); - IdentityResult result = new CredentialProcessHandler().tryResolve(source, ctx()); + IdentityResult result = createFromProfileResult(source); assertNull(result.identity()); assertTrue(result.error().contains("SecretAccessKey")); @@ -108,33 +112,7 @@ void missingRequiredFieldsReturnsError(@TempDir Path tmp) throws IOException { @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); - - var resolver = ProfileIdentityResolver.builder(AwsCredentialsIdentity.class) - .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()); + assertNull(createFromProfileResult(other)); } private static Path writeScript(Path tmp, String content) throws IOException { @@ -144,7 +122,29 @@ private static Path writeScript(Path tmp, String content) throws IOException { return script; } - private static ResolutionContext ctx() { - return new ResolutionContext(null, "test", Context.empty()); + private IdentityResult createFromProfileResult(AwsConfigCredentialSource source) { + if (!(source instanceof AwsConfigCredentialSource.CredentialProcess cp)) { + return null; + } + var handler = new CredentialProcessHandler(); + var ctx = new ProviderContext(null); + try { + Path configPath = Files.createTempFile("aws-config", ".ini"); + Files.writeString(configPath, "[default]\ncredential_process=" + cp.commandLine() + "\n"); + var file = AwsProfileFile.builder().configFile(configPath).credentialsFile(null).build(); + ctx.setProfileFile(file); + ctx.setProfile(file.activeProfile()); + } catch (IOException e) { + throw new RuntimeException(e); + } + var result = handler.create(AwsCredentialsIdentity.class, ctx); + if (!(result instanceof CreateResult.PossibleMatch m)) { + return null; + } + IdentityResolver resolver = m.resolver(); + if (resolver == null) { + return null; + } + return resolver.resolveIdentity(Context.empty()); } } diff --git a/aws/aws-credential-chain/src/test/resources/software/amazon/smithy/java/aws/credentials/chain/config/config-file-location-tests.json b/aws/aws-credential-chain/src/test/resources/software/amazon/smithy/java/aws/credentials/chain/config/config-file-location-tests.json new file mode 100644 index 000000000..2029792d8 --- /dev/null +++ b/aws/aws-credential-chain/src/test/resources/software/amazon/smithy/java/aws/credentials/chain/config/config-file-location-tests.json @@ -0,0 +1,135 @@ +{ + "description": "These are test descriptions that specify which files and profiles should be loaded based on the specified environment variables.", + + "tests": [ + { + "name": "User home is loaded from $HOME with highest priority on non-windows platforms.", + "environment": { + "HOME": "/home/user", + "USERPROFILE": "ignored", + "HOMEDRIVE": "ignored", + "HOMEPATH": "ignored" + }, + "languageSpecificHome": "ignored", + "platform": "linux", + "profile": "default", + "configLocation": "/home/user/.aws/config", + "credentialsLocation": "/home/user/.aws/credentials" + }, + + { + "name": "User home is loaded using language-specific resolution on non-windows platforms when $HOME is not set.", + "environment": { + "USERPROFILE": "ignored", + "HOMEDRIVE": "ignored", + "HOMEPATH": "ignored" + }, + "languageSpecificHome": "/home/user", + "platform": "linux", + "profile": "default", + "configLocation": "/home/user/.aws/config", + "credentialsLocation": "/home/user/.aws/credentials" + }, + + { + "name": "User home is loaded from $HOME with highest priority on windows platforms.", + "environment": { + "HOME": "C:\\users\\user", + "USERPROFILE": "ignored", + "HOMEDRIVE": "ignored", + "HOMEPATH": "ignored" + }, + "languageSpecificHome": "ignored", + "platform": "windows", + "profile": "default", + "configLocation": "C:\\users\\user\\.aws\\config", + "credentialsLocation": "C:\\users\\user\\.aws\\credentials" + }, + + { + "name": "User home is loaded from $USERPROFILE on windows platforms when $HOME is not set.", + "environment": { + "USERPROFILE": "C:\\users\\user", + "HOMEDRIVE": "ignored", + "HOMEPATH": "ignored" + }, + "languageSpecificHome": "ignored", + "platform": "windows", + "profile": "default", + "configLocation": "C:\\users\\user\\.aws\\config", + "credentialsLocation": "C:\\users\\user\\.aws\\credentials" + }, + + { + "name": "User home is loaded from $HOMEDRIVE$HOMEPATH on windows platforms when $HOME and $USERPROFILE are not set.", + "environment": { + "HOMEDRIVE": "C:", + "HOMEPATH": "\\users\\user" + }, + "languageSpecificHome": "ignored", + "platform": "windows", + "profile": "default", + "configLocation": "C:\\users\\user\\.aws\\config", + "credentialsLocation": "C:\\users\\user\\.aws\\credentials" + }, + + { + "name": "User home is loaded using language-specific resolution on windows platforms when no environment variables are set.", + "environment": { + }, + "languageSpecificHome": "C:\\users\\user", + "platform": "windows", + "profile": "default", + "configLocation": "C:\\users\\user\\.aws\\config", + "credentialsLocation": "C:\\users\\user\\.aws\\credentials" + }, + + { + "name": "The default config location can be overridden by the user on non-windows platforms.", + "environment": { + "AWS_CONFIG_FILE": "/other/path/config", + "HOME": "/home/user" + }, + "platform": "linux", + "profile": "default", + "configLocation": "/other/path/config", + "credentialsLocation": "/home/user/.aws/credentials" + }, + + { + "name": "The default credentials location can be overridden by the user on non-windows platforms.", + "environment": { + "AWS_SHARED_CREDENTIALS_FILE": "/other/path/credentials", + "HOME": "/home/user" + }, + "platform": "linux", + "profile": "default", + "configLocation": "/home/user/.aws/config", + "credentialsLocation": "/other/path/credentials" + }, + + { + "name": "The default credentials location can be overridden by the user on windows platforms.", + "environment": { + "AWS_CONFIG_FILE": "C:\\other\\path\\config", + "HOME": "C:\\users\\user" + }, + "platform": "windows", + "profile": "default", + "configLocation": "C:\\other\\path\\config", + "credentialsLocation": "C:\\users\\user\\.aws\\credentials" + }, + + { + "name": "The default credentials location can be overridden by the user on windows platforms.", + "environment": { + "AWS_SHARED_CREDENTIALS_FILE": "C:\\other\\path\\credentials", + "HOME": "C:\\users\\user" + }, + "platform": "windows", + "profile": "default", + "configLocation": "C:\\users\\user\\.aws\\config", + "credentialsLocation": "C:\\other\\path\\credentials" + } + ] +} \ No newline at end of file diff --git a/aws/aws-credential-chain/src/test/resources/software/amazon/smithy/java/aws/credentials/chain/config/config-file-parser-tests.json b/aws/aws-credential-chain/src/test/resources/software/amazon/smithy/java/aws/credentials/chain/config/config-file-parser-tests.json new file mode 100644 index 000000000..7bada6746 --- /dev/null +++ b/aws/aws-credential-chain/src/test/resources/software/amazon/smithy/java/aws/credentials/chain/config/config-file-parser-tests.json @@ -0,0 +1,1572 @@ +{ + "description": "These are test descriptions that describe how to convert a raw configuration and credentials file into an in-memory representation of the config file for profiles and sso-sessions.", + + "tests": [ + { + "name": "Empty files have no profiles.", + "input": { + "configFile" : "" + }, + "output": { + "profiles": {} + } + }, + + { + "name": "Empty profiles have no properties.", + "input": { + "configFile": "[profile foo]\n" + }, + "output": { + "profiles": { + "foo": {} + } + } + }, + + { + "name": "Profile definitions must end with brackets.", + "input": { + "configFile": "[profile foo" + }, + "output": { + "errorContaining": "Section definition must end with ']'" + } + }, + + { + "name": "Profile names should be trimmed.", + "input": { + "configFile": "[profile \tfoo \t]" + }, + "output": { + "profiles": { + "foo": {} + } + } + }, + + { + "name": "Tabs can separate profile names from the section.", + "input": { + "configFile": "[profile\tfoo]" + }, + "output": { + "profiles": { + "foo": {} + } + } + }, + + { + "name": "Properties must be defined in a section.", + "input": { + "configFile": "name = value" + }, + "output": { + "errorContaining": "Expected a section definition" + } + }, + + { + "name": "Profiles can contain properties.", + "input": { + "configFile": "[profile foo]\nname = value" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Windows style line endings are supported.", + "input": { + "configFile": "[profile foo]\r\nname = value" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Equals signs are supported in property values.", + "input": { + "configFile": "[profile foo]\nname = val=ue" + }, + "output": { + "profiles": { + "foo": { + "name": "val=ue" + } + } + } + }, + + { + "name": "Unicode characters are supported in property values.", + "input": { + "configFile": "[profile foo]\nname = 😂" + }, + "output": { + "profiles": { + "foo": { + "name": "😂" + } + } + } + }, + + { + "name": "Profiles can contain multiple properties.", + "input": { + "configFile": "[profile foo]\nname = value\nname2 = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value", + "name2": "value2" + } + } + } + }, + + { + "name": "Property keys and values are trimmed.", + "input": { + "configFile": "[profile foo]\nname \t= \tvalue \t" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Property values can be empty.", + "input": { + "configFile": "[profile foo]\nname =" + }, + "output": { + "profiles": { + "foo": { + "name": "" + } + } + } + }, + + { + "name": "Property key cannot be empty.", + "input": { + "configFile": "[profile foo]\n= value" + }, + "output": { + "errorContaining": "Property did not have a name" + } + }, + + { + "name": "Property definitions must contain an equals sign.", + "input": { + "configFile": "[profile foo]\nkey : value" + }, + "output": { + "errorContaining": "Expected an '=' sign defining a property" + } + }, + + { + "name": "Multiple profiles can be empty.", + "input": { + "configFile": "[profile foo]\n[profile bar]" + }, + "output": { + "profiles": { + "foo": {}, + "bar": {} + } + } + }, + + { + "name": "Multiple profiles can have properties.", + "input": { + "configFile": "[profile foo]\nname = value\n[profile bar]\nname2 = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + }, + "bar": { + "name2": "value2" + } + } + } + }, + + { + "name": "Blank lines are ignored.", + "input": { + "configFile": "\t \n[profile foo]\n\t\n \nname = value\n\t \n[profile bar]\n \t" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + }, + "bar": {} + } + } + }, + + { + "name": "Pound sign comments are ignored.", + "input": { + "configFile": "# Comment\n[profile foo] # Comment\nname = value # Comment with # sign" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Semicolon sign comments are ignored.", + "input": { + "configFile": "; Comment\n[profile foo] ; Comment\nname = value ; Comment with ; sign" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + { + "name": "All comment types can be used together.", + "input": { + "configFile": "# Comment\n[profile foo] ; Comment\nname = value # Comment with ; sign" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Comments can be empty.", + "input": { + "configFile": ";\n[profile foo];\nname = value ;\n" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Comments can be adjacent to profile names.", + "input": { + "configFile": "[profile foo]; Adjacent semicolons\n[profile bar]# Adjacent pound signs" + }, + "output": { + "profiles": { + "foo": {}, + "bar": {} + } + } + }, + + { + "name": "Comments adjacent to values are included in the value.", + "input": { + "configFile": "[profile foo]\nname = value; Adjacent semicolons\nname2 = value# Adjacent pound signs" + }, + "output": { + "profiles": { + "foo": { + "name": "value; Adjacent semicolons", + "name2": "value# Adjacent pound signs" + } + } + } + }, + + { + "name": "Property values can be continued on the next line.", + "input": { + "configFile": "[profile foo]\nname = value\n -continued" + }, + "output": { + "profiles": { + "foo": { + "name": "value\n-continued" + } + } + } + }, + + { + "name": "Property values can be continued with multiple lines.", + "input": { + "configFile": "[profile foo]\nname = value\n -continued\n -and-continued" + }, + "output": { + "profiles": { + "foo": { + "name": "value\n-continued\n-and-continued" + } + } + } + }, + + { + "name": "Continuations are trimmed.", + "input": { + "configFile": "[profile foo]\nname = value\n \t -continued \t " + }, + "output": { + "profiles": { + "foo": { + "name": "value\n-continued" + } + } + } + }, + + { + "name": "Continuation values include pound comments.", + "input": { + "configFile": "[profile foo]\nname = value\n -continued # Comment" + }, + "output": { + "profiles": { + "foo": { + "name": "value\n-continued # Comment" + } + } + } + }, + + { + "name": "Continuation values include semicolon comments.", + "input": { + "configFile": "[profile foo]\nname = value\n -continued ; Comment" + }, + "output": { + "profiles": { + "foo": { + "name": "value\n-continued ; Comment" + } + } + } + }, + + { + "name": "Continuations cannot be used outside of a profile.", + "input": { + "configFile": " -continued" + }, + "output": { + "errorContaining": "Expected a section definition" + } + }, + + { + "name": "Continuations cannot be used outside of a property.", + "input": { + "configFile": "[profile foo]\n -continued" + }, + "output": { + "errorContaining": "Expected a property definition" + } + }, + + { + "name": "Continuations reset with profile definitions.", + "input": { + "configFile": "[profile foo]\nname = value\n[profile foo]\n -continued" + }, + "output": { + "errorContaining": "Expected a property definition" + } + }, + + { + "name": "Duplicate profiles in the same file merge properties.", + "input": { + "configFile": "[profile foo]\nname = value\n[profile foo]\nname2 = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value", + "name2": "value2" + } + } + } + }, + + { + "name": "Duplicate properties in a profile use the last one defined.", + "input": { + "configFile": "[profile foo]\nname = value\nname = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "Duplicate properties in duplicate profiles use the last one defined.", + "input": { + "configFile": "[profile foo]\nname = value\n[profile foo]\nname = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "Default profile with profile prefix overrides default profile without prefix when profile prefix is first.", + "input": { + "configFile": "[profile default]\nname = value\n[default]\nname2 = value2" + }, + "output": { + "profiles": { + "default": { + "name": "value" + } + } + } + }, + + { + "name": "Default profile with profile prefix overrides default profile without prefix when profile prefix is last.", + "input": { + "configFile": "[default]\nname2 = value2\n[profile default]\nname = value" + }, + "output": { + "profiles": { + "default": { + "name": "value" + } + } + } + }, + + { + "name": "Invalid profile names are ignored.", + "input": { + "configFile": "[profile in valid]\nname = value", + "credentialsFile": "[in valid 2]\nname2 = value2" + }, + "output": { + "profiles": {} + } + }, + + { + "name": "Invalid property names are ignored.", + "input": { + "configFile": "[profile foo]\nin valid = value" + }, + "output": { + "profiles": { + "foo": {} + } + } + }, + + { + "name": "All valid identifier characters are supported.", + "input": { + "configFile": "[profile ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+]" + }, + "output": { + "profiles": { + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+": {} + } + } + }, + + { + "name": "All valid property name characters are supported.", + "input": { + "configFile": "[profile foo]\nABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+ = value" + }, + "output": { + "profiles": { + "foo": { + "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+": "value" + } + } + } + }, + + { + "name": "Properties can have sub-properties.", + "input": { + "configFile": "[profile foo]\ns3 =\n name = value" + }, + "output": { + "profiles": { + "foo": { + "s3": { + "name": "value" + } + } + } + } + }, + + { + "name": "Invalid sub-property definitions cause an error.", + "input": { + "configFile": "[profile foo]\ns3 =\n invalid" + }, + "output": { + "errorContaining": "Expected an '=' sign defining a property in sub-property" + } + }, + + { + "name": "Sub-property definitions can have an empty value.", + "input": { + "configFile": "[profile foo]\ns3 =\n name =" + }, + "output": { + "profiles": { + "foo": { + "s3": { + "name": "" + } + } + } + } + }, + + { + "name": "Sub property definitions have pound comments applied to the value.", + "input": { + "configFile": "[profile foo]\ns3 =\n name = value # Comment" + }, + "output": { + "profiles": { + "foo": { + "s3": { + "name": "value # Comment" + } + } + } + } + }, + + { + "name": "Sub property definitions have semicolon comments applied to the value.", + "input": { + "configFile": "[profile foo]\ns3 =\n name = value ; Comment" + }, + "output": { + "profiles": { + "foo": { + "s3": { + "name": "value ; Comment" + } + } + } + } + }, + + { + "name": "Sub-property definitions cannot have an empty name.", + "input": { + "configFile": "[profile foo]\ns3 =\n = value" + }, + "output": { + "errorContaining": "Property did not have a name in sub-property" + } + }, + + { + "name": "Sub-property definitions cannot have an invalid name.", + "input": { + "configFile": "[profile foo]\ns3 =\n in valid = value" + }, + "output": { + "profiles": { + "foo": { + "s3": {} + } + } + } + }, + + { + "name": "Sub-properties can have blank lines that are ignored", + "input": { + "configFile": "[profile foo]\ns3 =\n name = value\n\t \n name2 = value2" + }, + "output": { + "profiles": { + "foo": { + "s3": { + "name": "value", + "name2": "value2" + } + } + } + } + }, + + { + "name": "Profiles duplicated in multiple files are merged.", + "input": { + "configFile": "[profile foo]\nname = value", + "credentialsFile": "[foo]\nname2 = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value", + "name2": "value2" + } + } + } + }, + + { + "name": "Default profiles with mixed prefixes in the config file ignore the one without prefix when merging.", + "input": { + "configFile": "[profile default]\nname = value\n[default]\nname2 = value2\n[profile default]\nname3 = value3" + }, + "output": { + "profiles": { + "default": { + "name": "value", + "name3": "value3" + } + } + } + }, + + { + "name": "Default profiles with mixed prefixes merge with credentials", + "input": { + "configFile": "[profile default]\nname = value\n[default]\nname2 = value2\n[profile default]\nname3 = value3", + "credentialsFile": "[default]\nsecret=foo" + }, + "output": { + "profiles": { + "default": { + "name": "value", + "name3": "value3", + "secret": "foo" + } + } + } + }, + + { + "name": "Duplicate properties between files uses credentials property.", + "input": { + "configFile": "[profile foo]\nname = value", + "credentialsFile": "[foo]\nname = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "Config profiles without prefix are ignored.", + "input": { + "configFile": "[foo]\nname = value" + }, + "output": { + "profiles": {} + } + }, + + { + "name": "Credentials profiles with prefix are ignored.", + "input": { + "credentialsFile": "[profile foo]\nname = value" + }, + "output": { + "profiles": {} + } + }, + + { + "name": "Comment characters adjacent to profile decls", + "input": { + "configFile": "[profile foo]; semicolon\n[profile bar]# pound" + }, + "output": { + "profiles": { + "foo": {}, + "bar": {} + } + } + }, + + { + "name": "Invalid continuation", + "input": { + "configFile": "[profile foo]\nname = value\n[profile foo]\n -continued" + }, + "output": { + "errorContaining": "Expected a property definition, found continuation" + } + }, + + { + "name": "profile name with no space after `profile` is invalid", + "input": { + "configFile": "[profilefoo]\nname = value\n[profile bar]" + }, + "output": { + "profiles": { + "bar": {} + } + } + }, + + { + "name": "profile name with extra whitespace", + "input": { + "configFile": "[ profile foo ]\nname = value\n[profile bar]" + }, + "output": { + "profiles": { + "bar": {}, + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "profile name with extra whitespace in credentials", + "input": { + "credentialsFile": "[ foo ]\nname = value\n[profile bar]" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "properties from an invalid profile name are ignored", + "input": { + "configFile": "[profile foo]\nname = value\n[profile in valid]\nx = 1\n[profile bar]\nname = value2" + }, + "output": { + "profiles": { + "bar": { + "name": "value2" + }, + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Duplicate properties in duplicate profiles use the last one defined (case insensitive).", + "input": { + "configFile": "[profile foo]\nName = value\n[profile foo]\nname = value2" + }, + "output": { + "profiles": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "Empty files have no sso sessions.", + "input": { + "configFile": "" + }, + "output": { + "ssoSessions": {} + } + }, + + { + "name": "Empty sso sessions have no properties.", + "input": { + "configFile": "[sso-session foo]\n" + }, + "output": { + "ssoSessions": { + "foo": {} + } + } + }, + + { + "name": "sso-sessions without a name are ignored.", + "input": { + "configFile": "[sso-session]\nname = value" + }, + "output": { + "ssoSessions": {} + } + }, + + { + "name": "sso-session definitions must end with brackets.", + "input": { + "configFile": "[sso-session foo" + }, + "output": { + "errorContaining": "Section definition must end with ']'" + } + }, + + { + "name": "sso-session names should be trimmed.", + "input": { + "configFile": "[sso-session \tfoo \t]" + }, + "output": { + "ssoSessions": { + "foo": {} + } + } + }, + + { + "name": "Tabs can separate sso-session names from the section.", + "input": { + "configFile": "[sso-session\tfoo]" + }, + "output": { + "ssoSessions": { + "foo": {} + } + } + }, + + { + "name": "sso-sessions can contain properties.", + "input": { + "configFile": "[sso-session foo]\nname = value" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Windows style line endings are supported.", + "input": { + "configFile": "[sso-session foo]\r\nname = value" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Equals signs are supported in property values.", + "input": { + "configFile": "[sso-session foo]\nname = val=ue" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "val=ue" + } + } + } + }, + + { + "name": "Unicode characters are supported in property values.", + "input": { + "configFile": "[sso-session foo]\nname = 😂" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "😂" + } + } + } + }, + + { + "name": "sso-sessions can contain multiple properties.", + "input": { + "configFile": "[sso-session foo]\nname = value\nname2 = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value", + "name2": "value2" + } + } + } + }, + + { + "name": "Property keys and values are trimmed.", + "input": { + "configFile": "[sso-session foo]\nname \t= \tvalue \t" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Property values can be empty.", + "input": { + "configFile": "[sso-session foo]\nname =" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "" + } + } + } + }, + + { + "name": "Property key cannot be empty.", + "input": { + "configFile": "[sso-session foo]\n= value" + }, + "output": { + "errorContaining": "Property did not have a name" + } + }, + + { + "name": "Property definitions must contain an equals sign.", + "input": { + "configFile": "[sso-session foo]\nkey : value" + }, + "output": { + "errorContaining": "Expected an '=' sign defining a property" + } + }, + + { + "name": "Multiple sso-sessions can be empty.", + "input": { + "configFile": "[sso-session foo]\n[sso-session bar]" + }, + "output": { + "ssoSessions": { + "foo": {}, + "bar": {} + } + } + }, + + { + "name": "Multiple sso-sessions can have properties.", + "input": { + "configFile": "[sso-session foo]\nname = value\n[sso-session bar]\nname2 = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + }, + "bar": { + "name2": "value2" + } + } + } + }, + + { + "name": "Blank lines are ignored.", + "input": { + "configFile": "\t \n[sso-session foo]\n\t\n \nname = value\n\t \n[sso-session bar]\n \t" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + }, + "bar": {} + } + } + }, + + { + "name": "Pound sign comments are ignored.", + "input": { + "configFile": "# Comment\n[sso-session foo] # Comment\nname = value # Comment with # sign" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Semicolon sign comments are ignored.", + "input": { + "configFile": "; Comment\n[sso-session foo] ; Comment\nname = value ; Comment with ; sign" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "All comment types can be used together.", + "input": { + "configFile": "# Comment\n[sso-session foo] ; Comment\nname = value # Comment with ; sign" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Comments can be empty.", + "input": { + "configFile": ";\n[sso-session foo];\nname = value ;\n" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Comments can be adjacent to sso-session names.", + "input": { + "configFile": "[sso-session foo]; Adjacent semicolons\n[sso-session bar]# Adjacent pound signs" + }, + "output": { + "ssoSessions": { + "foo": {}, + "bar": {} + } + } + }, + + { + "name": "Comments adjacent to values are included in the value.", + "input": { + "configFile": "[sso-session foo]\nname = value; Adjacent semicolons\nname2 = value# Adjacent pound signs" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value; Adjacent semicolons", + "name2": "value# Adjacent pound signs" + } + } + } + }, + + { + "name": "Property values can be continued on the next line.", + "input": { + "configFile": "[sso-session foo]\nname = value\n -continued" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value\n-continued" + } + } + } + }, + + { + "name": "Property values can be continued with multiple lines.", + "input": { + "configFile": "[sso-session foo]\nname = value\n -continued\n -and-continued" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value\n-continued\n-and-continued" + } + } + } + }, + + { + "name": "Continuations are trimmed.", + "input": { + "configFile": "[sso-session foo]\nname = value\n \t -continued \t " + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value\n-continued" + } + } + } + }, + + { + "name": "Continuation values include pound comments.", + "input": { + "configFile": "[sso-session foo]\nname = value\n -continued # Comment" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value\n-continued # Comment" + } + } + } + }, + + { + "name": "Continuation values include semicolon comments.", + "input": { + "configFile": "[sso-session foo]\nname = value\n -continued ; Comment" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value\n-continued ; Comment" + } + } + } + }, + + { + "name": "Continuations cannot be used outside of a sso-session.", + "input": { + "configFile": " -continued" + }, + "output": { + "errorContaining": "Expected a section definition" + } + }, + + { + "name": "Continuations cannot be used outside of a property.", + "input": { + "configFile": "[sso-session foo]\n -continued" + }, + "output": { + "errorContaining": "Expected a property definition" + } + }, + + { + "name": "Continuations reset with sso-session definitions.", + "input": { + "configFile": "[sso-session foo]\nname = value\n[sso-session foo]\n -continued" + }, + "output": { + "errorContaining": "Expected a property definition" + } + }, + + { + "name": "Duplicate sso-sessions in the same file merge properties.", + "input": { + "configFile": "[sso-session foo]\nname = value\n[sso-session foo]\nname2 = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value", + "name2": "value2" + } + } + } + }, + + { + "name": "Duplicate properties in an sso-session use the last one defined.", + "input": { + "configFile": "[sso-session foo]\nname = value\nname = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "Duplicate properties in duplicate sso-sessions use the last one defined.", + "input": { + "configFile": "[sso-session foo]\nname = value\n[sso-session foo]\nname = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "Invalid sso-session names are ignored.", + "input": { + "configFile": "[sso-session in valid]\nname = value" + }, + "output": { + "ssoSessions": {} + } + }, + + { + "name": "Invalid property names are ignored.", + "input": { + "configFile": "[sso-session foo]\nin valid = value" + }, + "output": { + "ssoSessions": { + "foo": {} + } + } + }, + + { + "name": "All valid identifier characters are supported.", + "input": { + "configFile": "[sso-session ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+]" + }, + "output": { + "ssoSessions": { + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+": {} + } + } + }, + + { + "name": "All valid property name characters are supported.", + "input": { + "configFile": "[sso-session foo]\nABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+ = value" + }, + "output": { + "ssoSessions": { + "foo": { + "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz0123456789_-/.%@:+": "value" + } + } + } + }, + + { + "name": "Properties can have sub-properties.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n name = value" + }, + "output": { + "ssoSessions": { + "foo": { + "s3": { + "name": "value" + } + } + } + } + }, + + { + "name": "Invalid sub-property definitions cause an error.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n invalid" + }, + "output": { + "errorContaining": "Expected an '=' sign defining a property in sub-property" + } + }, + + { + "name": "Sub-property definitions can have an empty value.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n name =" + }, + "output": { + "ssoSessions": { + "foo": { + "s3": { + "name": "" + } + } + } + } + }, + + { + "name": "Sub property definitions have pound comments applied to the value.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n name = value # Comment" + }, + "output": { + "ssoSessions": { + "foo": { + "s3": { + "name": "value # Comment" + } + } + } + } + }, + + { + "name": "Sub property definitions have semicolon comments applied to the value.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n name = value ; Comment" + }, + "output": { + "ssoSessions": { + "foo": { + "s3": { + "name": "value ; Comment" + } + } + } + } + }, + + { + "name": "Sub-property definitions cannot have an empty name.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n = value" + }, + "output": { + "errorContaining": "Property did not have a name in sub-property" + } + }, + + { + "name": "Sub-property definitions cannot have an invalid name.", + "input": { + "configFile": "[sso-session foo]\ns3 =\n in valid = value" + }, + "output": { + "ssoSessions": { + "foo": { + "s3": {} + } + } + } + }, + + { + "name": "Sub-properties can have blank lines that are ignored", + "input": { + "configFile": "[sso-session foo]\ns3 =\n name = value\n\t \n name2 = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "s3": { + "name": "value", + "name2": "value2" + } + } + } + } + }, + + { + "name": "Config profiles without prefix are ignored.", + "input": { + "configFile": "[foo]\nname = value" + }, + "output": { + "ssoSessions": {} + } + }, + + { + "name": "Comment characters adjacent to sso-session decls", + "input": { + "configFile": "[sso-session foo]; semicolon\n[sso-session bar]# pound" + }, + "output": { + "ssoSessions": { + "foo": {}, + "bar": {} + } + } + }, + + { + "name": "Invalid continuation", + "input": { + "configFile": "[sso-session foo]\nname = value\n[sso-session foo]\n -continued" + }, + "output": { + "errorContaining": "Expected a property definition, found continuation" + } + }, + + { + "name": "profile name with no space after `sso-session` is invalid", + "input": { + "configFile": "[sso-sessionfoo]\nname = value\n[sso-session bar]" + }, + "output": { + "ssoSessions": { + "bar": {} + } + } + }, + + { + "name": "sso-session name with extra whitespace", + "input": { + "configFile": "[ sso-session foo ]\nname = value\n[sso-session bar]" + }, + "output": { + "ssoSessions": { + "bar": {}, + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "properties from an invalid sso-session name are ignored", + "input": { + "configFile": "[sso-session foo]\nname = value\n[sso-session in valid]\nx = 1\n[sso-session bar]\nname = value2" + }, + "output": { + "ssoSessions": { + "bar": { + "name": "value2" + }, + "foo": { + "name": "value" + } + } + } + }, + + { + "name": "Duplicate properties in duplicate sso-sessions use the last one defined (case insensitive).", + "input": { + "configFile": "[sso-session foo]\nName = value\n[sso-session foo]\nname = value2" + }, + "output": { + "ssoSessions": { + "foo": { + "name": "value2" + } + } + } + }, + + { + "name": "sso-sessions in the credentials file are ignored.", + "input": { + "credentialsFile": "[sso-session foo]\nName = value" + }, + "output": { + "ssoSessions": {} + } + }, + + { + "name": "Profile and sso-session can share names.", + "input": { + "configFile": "[profile foo]\nname = value\n[sso-session foo]\nname = value" + }, + "output": { + "profiles": { + "foo": { + "name": "value" + } + }, + "ssoSessions": { + "foo": { + "name": "value" + } + } + } + } + + ] +} \ No newline at end of file diff --git a/aws/aws-credentials-imds/build.gradle.kts b/aws/aws-credentials-imds/build.gradle.kts index 554978572..30a1d5d7d 100644 --- a/aws/aws-credentials-imds/build.gradle.kts +++ b/aws/aws-credentials-imds/build.gradle.kts @@ -11,7 +11,6 @@ dependencies { api(project(":aws:aws-auth-api")) api(project(":auth-api")) implementation(project(":aws:aws-credential-chain")) - implementation(project(":aws:aws-config")) implementation(project(":logging")) implementation(project(":codecs:json-codec")) diff --git a/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java b/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java index 089f7b11b..e67ef9710 100644 --- a/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java +++ b/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java @@ -13,13 +13,13 @@ import java.util.Set; import software.amazon.smithy.java.auth.api.identity.CachingIdentityResolver; import software.amazon.smithy.java.auth.api.identity.Identity; -import software.amazon.smithy.java.auth.api.identity.IdentityResolver; import software.amazon.smithy.java.auth.api.identity.IdentityResult; import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsResolver; import software.amazon.smithy.java.aws.config.AwsProfile; import software.amazon.smithy.java.aws.config.AwsProfileFile; import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; +import software.amazon.smithy.java.aws.credentials.chain.CreateResult; import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; @@ -60,14 +60,14 @@ public OrderingConstraint ordering() { @Override @SuppressWarnings("unchecked") - public IdentityResolver create(Class identityType, ProviderContext context) { + public CreateResult create(Class identityType, ProviderContext context) { if (identityType != AwsCredentialsIdentity.class) { - return null; + return CreateResult.pass(); } - AwsProfileFile profileFile = context.properties().get(AwsProfileFile.CONTEXT_KEY); + AwsProfileFile profileFile = context.profileFile(); if (isDisabled(profileFile)) { - return (IdentityResolver) new DisabledResolver(); + return CreateResult.pass(); } URI endpoint = resolveEndpoint(); @@ -75,10 +75,10 @@ public IdentityResolver create(Class identityType, Pr ImdsClient client = new ImdsClient(endpoint); AwsCredentialsResolver delegate = ctx -> fetchAndParse(client, profileName); - return (IdentityResolver) CachingIdentityResolver.builder(delegate) + return (CreateResult) new CreateResult.PossibleMatch<>(CachingIdentityResolver.builder(delegate) .executor(context.executor()) .allowExpiredCredentials(true) - .build(); + .build()); } private static IdentityResult fetchAndParse(ImdsClient client, String profileName) { diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java index 4cbfa23f3..eedc6400c 100644 --- a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java @@ -7,9 +7,9 @@ import java.util.Set; import software.amazon.smithy.java.auth.api.identity.Identity; -import software.amazon.smithy.java.auth.api.identity.IdentityResolver; import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; +import software.amazon.smithy.java.aws.credentials.chain.CreateResult; import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; @@ -36,10 +36,11 @@ public OrderingConstraint ordering() { @Override @SuppressWarnings("unchecked") - public IdentityResolver create(Class identityType, ProviderContext context) { + public CreateResult create(Class identityType, ProviderContext context) { if (identityType == AwsCredentialsIdentity.class) { - return (IdentityResolver) EnvironmentVariableIdentityResolver.INSTANCE; + return (CreateResult< + I>) new CreateResult.UnconditionalMatch<>(EnvironmentVariableIdentityResolver.INSTANCE); } - return null; + return CreateResult.pass(); } } diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java index 560fbbae5..64acbb538 100644 --- a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java @@ -7,9 +7,9 @@ import java.util.Set; import software.amazon.smithy.java.auth.api.identity.Identity; -import software.amazon.smithy.java.auth.api.identity.IdentityResolver; import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; +import software.amazon.smithy.java.aws.credentials.chain.CreateResult; import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; @@ -36,10 +36,10 @@ public OrderingConstraint ordering() { @Override @SuppressWarnings("unchecked") - public IdentityResolver create(Class identityType, ProviderContext context) { + public CreateResult create(Class identityType, ProviderContext context) { if (identityType == AwsCredentialsIdentity.class) { - return (IdentityResolver) SystemPropertiesIdentityResolver.INSTANCE; + return (CreateResult) new CreateResult.UnconditionalMatch<>(SystemPropertiesIdentityResolver.INSTANCE); } - return null; + return CreateResult.pass(); } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 9f86b4b53..2603e8244 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -86,8 +86,8 @@ include(":aws:client:aws-client-rulesengine") include(":aws:integrations:aws-lambda-endpoint") include(":aws:server:aws-server-restjson") include(":aws:aws-auth-api") -include(":aws:aws-config") include(":aws:aws-credential-chain") +include(":aws:aws-config") include(":aws:aws-credentials-imds") // AWS service bundling code From ad20b041189cfa5c3c8d959212817c7d1cf1f40a Mon Sep 17 00:00:00 2001 From: Michael Dowling Date: Thu, 14 May 2026 11:47:57 -0500 Subject: [PATCH 12/13] Add mutable ChainSetup --- .../chain/ChainIdentityProvider.java | 27 ++- .../aws/credentials/chain/ChainSetup.java | 114 +++++++++++ .../aws/credentials/chain/CreateResult.java | 48 ----- .../credentials/chain/CredentialChain.java | 179 +++++++++--------- .../credentials/chain/ProviderContext.java | 114 ----------- .../credentials/chain/StandardProvider.java | 71 ++++--- .../config/CredentialProcessHandler.java | 26 +-- .../chain/config/SessionKeysHandler.java | 65 +++---- .../chain/config/SharedConfigProvider.java | 21 +- .../chain/config/StaticKeysHandler.java | 66 +++---- .../chain/AwsCredentialChainTest.java | 11 +- .../aws/credentials/chain/FeatureIdTest.java | 12 +- .../config/CredentialProcessHandlerTest.java | 23 ++- .../imds/ImdsCredentialProvider.java | 24 ++- .../EnvironmentCredentialProvider.java | 18 +- .../SystemPropertiesCredentialProvider.java | 17 +- 16 files changed, 347 insertions(+), 489 deletions(-) create mode 100644 aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainSetup.java delete mode 100644 aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CreateResult.java delete mode 100644 aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ProviderContext.java diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainIdentityProvider.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainIdentityProvider.java index 57686c304..2808ebda4 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainIdentityProvider.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainIdentityProvider.java @@ -11,8 +11,10 @@ /** * SPI for registering an identity provider into a credential/token chain. * - *

      A single provider can support multiple identity types by checking the requested type in - * {@link #create(Class, ProviderContext)} and returning {@code null} for unsupported types. + *

      Implementations are discovered via the language's plugin mechanism (e.g., {@code ServiceLoader} + * in Java) and sorted by {@link #ordering()} before {@link #create} is called. A provider + * registers its resolver by calling {@link ChainSetup#addResolver} or + * {@link ChainSetup#addTerminalResolver} from within {@code create()}. */ public interface ChainIdentityProvider { /** @@ -35,22 +37,15 @@ default Set featureIds() { } /** - * Create the identity resolver for the requested identity type. + * Called once during chain assembly in sorted order. The provider inspects the identity + * type and shared state on the setup, then optionally calls {@link ChainSetup#addResolver} + * or {@link ChainSetup#addTerminalResolver} to register a resolver. * - *

      Called once during chain assembly in slot order. Return: - *

        - *
      • {@link CreateResult#pass()} — this provider does not participate
      • - *
      • {@link CreateResult.PossibleMatch} — resolver added, assembly continues
      • - *
      • {@link CreateResult.UnconditionalMatch} — resolver added, assembly stops
      • - *
      - * - *

      Providers that need AWS config file data should read it from - * {@link ProviderContext#profile()}, populated by the {@code SHARED_CONFIG} provider. + *

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

      When {@link #addTerminalResolver} is called, assembly stops — no further providers are invoked. + */ +public final class ChainSetup { + private final ScheduledExecutorService executor; + private final String profileNameOverride; + private final Context properties; + private final List resolvers = new ArrayList<>(); + private AwsProfileFile profileFile; + private AwsProfile profile; + private boolean terminal; + + // Current provider being assembled (set by the chain before calling create()) + private ChainIdentityProvider currentProvider; + + public ChainSetup(ScheduledExecutorService executor) { + this(executor, null); + } + + public ChainSetup(ScheduledExecutorService executor, String profileNameOverride) { + this.executor = executor; + this.profileNameOverride = profileNameOverride; + this.properties = Context.create(); + } + + /** Shared executor for background credential refresh. */ + public ScheduledExecutorService executor() { + return executor; + } + + /** Client-specified profile name override, or {@code null} for default resolution. */ + public String profileNameOverride() { + return profileNameOverride; + } + + /** General-purpose property bag for sharing state between providers. */ + public Context properties() { + return properties; + } + + /** The parsed AWS config/credentials file, or {@code null} if not yet loaded. */ + public AwsProfileFile profileFile() { + return profileFile; + } + + /** Sets the parsed profile file. Called by the SHARED_CONFIG provider. */ + public void setProfileFile(AwsProfileFile profileFile) { + this.profileFile = profileFile; + } + + /** The active profile, or {@code null} if not yet loaded. */ + public AwsProfile profile() { + return profile; + } + + /** Sets the active profile. Called by the SHARED_CONFIG provider. */ + public void setProfile(AwsProfile profile) { + this.profile = profile; + } + + /** + * Registers a resolver at the current provider's position. Assembly continues. + */ + public void addResolver(IdentityResolver resolver) { + resolvers.add(new NamedResolver(currentProvider.name(), currentProvider.featureIds(), resolver)); + } + + /** + * Registers a resolver and stops assembly. No further providers will be called. + */ + public void addTerminalResolver(IdentityResolver resolver) { + resolvers.add(new NamedResolver(currentProvider.name(), currentProvider.featureIds(), resolver)); + this.terminal = true; + } + + // --- Package-private, used by CredentialChain --- + + public void setCurrentProvider(ChainIdentityProvider provider) { + this.currentProvider = provider; + } + + boolean isTerminal() { + return terminal; + } + + public List resolvers() { + return resolvers; + } + + public record NamedResolver( + String name, + Set featureIds, + IdentityResolver resolver) {} +} diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CreateResult.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CreateResult.java deleted file mode 100644 index 08b7c73e1..000000000 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CreateResult.java +++ /dev/null @@ -1,48 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.java.aws.credentials.chain; - -import software.amazon.smithy.java.auth.api.identity.Identity; -import software.amazon.smithy.java.auth.api.identity.IdentityResolver; - -/** - * The result of {@link ChainIdentityProvider#create}. Indicates whether the provider - * participates in the chain and whether it is unconditionally authoritative. - * - * @param the identity type. - */ -public sealed interface CreateResult { - /** - * This provider does not participate in the chain (preconditions not met). - */ - record Pass() implements CreateResult {} - - /** - * This provider might resolve credentials, but resolution could fail at request time - * (e.g., IMDS network call, STS assume-role). Assembly continues to discover fallback providers. - * - * @param resolver the resolver to add to the chain. - */ - record PossibleMatch(IdentityResolver resolver) implements CreateResult {} - - /** - * This provider will unconditionally resolve credentials (e.g., environment variables are set, - * static keys are present). Assembly stops — no further providers are needed. - * - * @param resolver the resolver to add to the chain. - */ - record UnconditionalMatch(IdentityResolver resolver) implements CreateResult {} - - /** - * Returns a {@link Pass} result. - */ - @SuppressWarnings("unchecked") - static CreateResult pass() { - return (CreateResult) PASS; - } - - Pass PASS = new Pass<>(); -} diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialChain.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialChain.java index 08ad89e0e..ebbf48087 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialChain.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialChain.java @@ -7,11 +7,9 @@ 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; @@ -46,17 +44,12 @@ public final class CredentialChain implements IdentityResolv private static final InternalLogger LOGGER = InternalLogger.getLogger(CredentialChain.class); private final Class identityType; - private final List> resolvers; + private final List resolvers; private final ScheduledExecutorService executor; - private record NamedResolver( - String name, - Set featureIds, - IdentityResolver resolver) {} - private CredentialChain( Class identityType, - List> resolvers, + List resolvers, ScheduledExecutorService executor ) { this.identityType = identityType; @@ -108,105 +101,102 @@ static CredentialChain assemble( } } - // Separate standards from relatives. - Map standards = new EnumMap<>(StandardProvider.class); - List relatives = new ArrayList<>(); + // Sort providers by ordering constraint (enum order for Standard, relative for Before/After). + List sorted = sortByOrdering(registrations); - for (ChainIdentityProvider r : registrations) { - if (r.ordering() instanceof OrderingConstraint.Standard(StandardProvider slot)) { - ChainIdentityProvider existing = standards.put(slot, r); - if (existing != null) { - throw new IllegalStateException("Two credential providers claim the same slot '" - + slot + "': '" + existing.name() + "' and '" + r.name() + "'"); - } - } else { - relatives.add(r); + // Call create() on each provider in sorted order. + ChainSetup setup = new ChainSetup(executor); + + for (ChainIdentityProvider provider : sorted) { + setup.setCurrentProvider(provider); + provider.create(identityType, setup); + if (setup.isTerminal()) { + break; } } - // Use a single executor for each provider (used for caching). - // Parse config files and resolve active profile before calling providers. - ProviderContext ctx = new ProviderContext(executor); + List ordered = setup.resolvers(); - // Precompute insert positions: for each slot, how many claimed slots come before it - // and up to and including it. This avoids re-scanning the enum on every relative insert. - EnumMap insertAfter = new EnumMap<>(StandardProvider.class); - EnumMap insertBefore = new EnumMap<>(StandardProvider.class); - int count = 0; - for (StandardProvider slot : StandardProvider.values()) { - insertBefore.put(slot, count); - if (standards.containsKey(slot)) { - count++; - } - insertAfter.put(slot, count); + if (LOGGER.isDebugEnabled()) { + LOGGER.debug("Assembled credential chain: {}", + ordered.stream().map(ChainSetup.NamedResolver::name).collect(Collectors.joining(", "))); } - // Build the ordered list: standard slots in enum order. - List> ordered = new ArrayList<>(); - boolean done = false; - for (StandardProvider slot : StandardProvider.values()) { - if (done) { - break; - } - ChainIdentityProvider r = standards.get(slot); - if (r != null) { - CreateResult result = r.create(identityType, ctx); - switch (result) { - case CreateResult.UnconditionalMatch(var resolver) -> { - ordered.add(new NamedResolver<>(r.name(), r.featureIds(), resolver)); - done = true; - } - case CreateResult.PossibleMatch(var resolver) -> - ordered.add(new NamedResolver<>(r.name(), r.featureIds(), resolver)); - case CreateResult.Pass ignored -> { - } + // Warn about detected-but-unclaimed slots. + Set claimed = new HashSet<>(); + for (var nr : ordered) { + for (ChainIdentityProvider p : sorted) { + if (p.name().equals(nr.name()) + && p.ordering() instanceof OrderingConstraint.Standard(StandardProvider s)) { + claimed.add(s); } } } + warnDetectedButUnclaimed(claimed); + return new CredentialChain<>(identityType, Collections.unmodifiableList(ordered), executor); + } - // Insert relative providers using precomputed positions (only if assembly wasn't short-circuited). - if (!done) { - for (ChainIdentityProvider r : relatives) { - int insertAt; - if (r.ordering() instanceof OrderingConstraint.After(StandardProvider slot)) { - insertAt = insertAfter.get(slot); - } else if (r.ordering() instanceof OrderingConstraint.Before(StandardProvider slot)) { - insertAt = insertBefore.get(slot); - } else { - insertAt = ordered.size(); - } - if (insertAt > ordered.size()) { - insertAt = ordered.size(); - } - CreateResult result = r.create(identityType, ctx); - switch (result) { - case CreateResult.UnconditionalMatch(var resolver) -> { - ordered.add(insertAt, new NamedResolver<>(r.name(), r.featureIds(), resolver)); - done = true; - } - case CreateResult.PossibleMatch(var resolver) -> - ordered.add(insertAt, new NamedResolver<>(r.name(), r.featureIds(), resolver)); - case CreateResult.Pass ignored -> { + private static List sortByOrdering(List providers) { + // Separate into standard-slot providers and relative providers. + List standards = new ArrayList<>(); + List befores = new ArrayList<>(); + List afters = new ArrayList<>(); + Set seenSlots = new HashSet<>(); + + for (ChainIdentityProvider p : providers) { + switch (p.ordering()) { + case OrderingConstraint.Standard(StandardProvider slot) -> { + if (!seenSlots.add(slot)) { + throw new IllegalStateException("Two providers claim the same standard slot '" + + slot + "': check provider '" + p.name() + "'"); } + standards.add(p); } - if (done) { - break; - } + case OrderingConstraint.Before b -> befores.add(p); + case OrderingConstraint.After a -> afters.add(p); } } - if (LOGGER.isDebugEnabled()) { - LOGGER.debug("Assembled credential chain: {}", - ordered.stream().map(NamedResolver::name).collect(Collectors.joining(", "))); + // Sort standards by enum ordinal. + standards.sort((a, b) -> { + var slotA = ((OrderingConstraint.Standard) a.ordering()).slot(); + var slotB = ((OrderingConstraint.Standard) b.ordering()).slot(); + return slotA.compareTo(slotB); + }); + + // Build final list: insert Before/After relative to their referenced slot's position. + List result = new ArrayList<>(standards); + for (ChainIdentityProvider p : befores) { + var slot = ((OrderingConstraint.Before) p.ordering()).slot(); + int idx = indexOfSlot(result, slot); + result.add(idx, p); + } + for (ChainIdentityProvider p : afters) { + var slot = ((OrderingConstraint.After) p.ordering()).slot(); + int idx = indexOfSlot(result, slot); + int insertAt = Math.min(idx + 1, result.size()); + result.add(insertAt, p); } + return result; + } - warnDetectedButUnclaimed(standards); - return new CredentialChain<>(identityType, Collections.unmodifiableList(ordered), executor); + private static int indexOfSlot(List list, StandardProvider slot) { + for (int i = 0; i < list.size(); i++) { + if (list.get(i).ordering() instanceof OrderingConstraint.Standard(StandardProvider s) && s == slot) { + return i; + } + // If slot not found, find where it would be by enum order. + if (list.get(i).ordering() instanceof OrderingConstraint.Standard(StandardProvider s) + && s.ordinal() > slot.ordinal()) { + return i; + } + } + return list.size(); } - private static void warnDetectedButUnclaimed(Map standards) { + private static void warnDetectedButUnclaimed(Set claimed) { for (StandardProvider slot : StandardProvider.values()) { - if (slot.moduleSuggestion() != null && !standards.containsKey(slot) && slot.isDetected()) { + if (slot.moduleSuggestion() != null && !claimed.contains(slot) && slot.isDetected()) { LOGGER.warn("{} credentials detected but no provider is registered for the '{}' slot. " + "Add '{}' to your dependencies.", slot.name(), @@ -228,17 +218,18 @@ public IdentityResult resolveIdentity(Context requestProperties) { List errors = new ArrayList<>(); for (var nr : resolvers) { - var result = nr.resolver.resolveIdentity(requestProperties); + @SuppressWarnings("unchecked") + var result = (IdentityResult) nr.resolver().resolveIdentity(requestProperties); if (result.identity() != null) { - if (!nr.featureIds.isEmpty()) { + if (!nr.featureIds().isEmpty()) { var ids = requestProperties.get(CallContext.FEATURE_IDS); if (ids != null) { - ids.addAll(nr.featureIds); + ids.addAll(nr.featureIds()); } } return result; } - errors.add(nr.name); + errors.add(nr.name()); errors.add(result.error()); } @@ -273,7 +264,7 @@ private String detectedButMissingHints() { private boolean isClaimed(StandardProvider slot) { for (var nr : resolvers) { - if (nr.name.equals(slot.name().toLowerCase(Locale.ROOT))) { + if (nr.name().equals(slot.name().toLowerCase(Locale.ROOT))) { return true; } } @@ -286,7 +277,7 @@ private boolean isClaimed(StandardProvider slot) { public List providerNames() { List names = new ArrayList<>(resolvers.size()); for (var nr : resolvers) { - names.add(nr.name); + names.add(nr.name()); } return names; } @@ -299,7 +290,7 @@ public Class identityType() { @Override public void invalidate() { for (var nr : resolvers) { - nr.resolver.invalidate(); + nr.resolver().invalidate(); } } diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ProviderContext.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ProviderContext.java deleted file mode 100644 index 3a31c1362..000000000 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ProviderContext.java +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -package software.amazon.smithy.java.aws.credentials.chain; - -import java.util.concurrent.ScheduledExecutorService; -import software.amazon.smithy.java.aws.config.AwsProfile; -import software.amazon.smithy.java.aws.config.AwsProfileFile; -import software.amazon.smithy.java.context.Context; - -/** - * Shared context passed to each {@link ChainIdentityProvider#create} during chain assembly. - * - *

      Providers earlier in the chain populate this context for downstream providers. In particular, - * the {@code SHARED_CONFIG} provider parses the AWS config/credentials files and calls - * {@link #setProfileFile} and {@link #setProfile} before any profile-based providers run. - * - *

      This class is mutable. Providers may read and write to it during assembly; the context - * is also accessible at request time by resolvers that close over it (e.g., for live reload - * after {@code invalidate()}). - */ -public final class ProviderContext { - private final ScheduledExecutorService executor; - private final Context properties; - private final String profileNameOverride; - private AwsProfileFile profileFile; - private AwsProfile profile; - - public ProviderContext(ScheduledExecutorService executor) { - this(executor, null); - } - - public ProviderContext(ScheduledExecutorService executor, String profileNameOverride) { - this.executor = executor; - this.properties = Context.create(); - this.profileNameOverride = profileNameOverride; - } - - /** - * Returns the shared executor for scheduling background credential refresh. - * - * @return the executor, or {@code null} if none was provided. - */ - public ScheduledExecutorService executor() { - return executor; - } - - /** - * Returns the client-specified profile name override, or {@code null} to use the - * default resolution order ({@code AWS_PROFILE} env var, {@code aws.profile} system - * property, then {@code "default"}). - * - * @return the profile name override, or {@code null}. - */ - public String profileNameOverride() { - return profileNameOverride; - } - - /** - * Returns a general-purpose typed property bag for sharing arbitrary state between providers. - * - * @return the property bag; never {@code null}. - */ - public Context properties() { - return properties; - } - - /** - * Returns the parsed AWS config/credentials file, or {@code null} if no config file was found. - * - *

      Populated by the {@code SHARED_CONFIG} provider at assembly time. May be refreshed - * in place after an {@code invalidate()} call. - * - * @return the profile file, or {@code null}. - */ - public AwsProfileFile profileFile() { - return profileFile; - } - - /** - * Sets the parsed profile file. Called by the {@code SHARED_CONFIG} provider during assembly - * and potentially during invalidation to refresh from disk. - * - * @param profileFile the parsed profile file. - */ - public void setProfileFile(AwsProfileFile profileFile) { - this.profileFile = profileFile; - } - - /** - * Returns the active AWS profile (resolved from {@code AWS_PROFILE} env var, - * {@code aws.profile} system property, or defaulting to {@code "default"}). - * - *

      Returns {@code null} if no config file was loaded or the active profile name - * does not exist in the file. - * - * @return the active profile, or {@code null}. - */ - public AwsProfile profile() { - return profile; - } - - /** - * Sets the active profile. Called by the {@code SHARED_CONFIG} provider during assembly - * and potentially during invalidation. - * - * @param profile the active profile. - */ - public void setProfile(AwsProfile profile) { - this.profile = profile; - } -} diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/StandardProvider.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/StandardProvider.java index 7e3e873f9..b71592ac3 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/StandardProvider.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/StandardProvider.java @@ -19,18 +19,6 @@ * (via {@link #moduleSuggestion()}). */ public enum StandardProvider { - /** - * Credentials explicitly provided in code (e.g., passed to a client builder). - * - *

      No detection signal — this slot is only claimed when credentials are programmatically set. - */ - CODE(null) { - @Override - public boolean isDetected() { - return false; - } - }, - /** * Credentials from JVM system properties. * @@ -93,7 +81,12 @@ public boolean isDetected() { * *

      Requires the STS module. Reads the active profile from {@link ProviderContext#profile()}. */ - PROFILE_WEB_IDENTITY("software.amazon.smithy.java:aws-credentials-sts") { + /** + * Profile-based static keys ({@code aws_access_key_id} + {@code aws_secret_access_key}). + * + *

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

      Requires the STS module. Reads the active profile from {@link ProviderContext#profile()}. + *

      Re-reads from the profile on each resolution to support live reload after invalidation. */ - PROFILE_ASSUME_ROLE("software.amazon.smithy.java:aws-credentials-sts") { + PROFILE_SESSION_KEYS(null) { @Override public boolean isDetected() { return false; @@ -114,12 +107,12 @@ public boolean isDetected() { }, /** - * Profile-based SSO session ({@code sso_session} + {@code sso_account_id} + - * {@code sso_role_name}). + * Profile-based assume role ({@code role_arn} with {@code source_profile} or + * {@code credential_source}). * - *

      Requires the SSO module. Reads the active profile from {@link ProviderContext#profile()}. + *

      Requires the STS module. Reads the active profile from {@link ChainSetup#profile()}. */ - PROFILE_SSO_SESSION("software.amazon.smithy.java:aws-credentials-sso") { + PROFILE_ASSUME_ROLE("software.amazon.smithy.java:aws-credentials-sts") { @Override public boolean isDetected() { return false; @@ -127,12 +120,11 @@ public boolean isDetected() { }, /** - * Profile-based legacy SSO ({@code sso_start_url} + {@code sso_account_id} + - * {@code sso_role_name} + {@code sso_region}). + * Profile-based web identity token ({@code web_identity_token_file} + {@code role_arn}). * - *

      Requires the SSO module. Reads the active profile from {@link ProviderContext#profile()}. + *

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

      Requires the login module. Reads the active profile from {@link ProviderContext#profile()}. + *

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

      Invokes an external process on each resolution. The command string is captured at - * assembly time from the active profile. + *

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

      Re-reads from the profile on each resolution to support live reload after invalidation. + *

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

      Re-reads from the profile on each resolution to support live reload after invalidation. + *

      Invokes an external process on each resolution. The command string is captured at + * assembly time from the active profile. */ - PROFILE_STATIC_KEYS(null) { + PROFILE_CREDENTIAL_PROCESS(null) { @Override public boolean isDetected() { return false; diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandler.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandler.java index 1aafd96ee..708e5dce9 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandler.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandler.java @@ -21,10 +21,9 @@ import software.amazon.smithy.java.aws.config.AwsConfigCredentialSource; import software.amazon.smithy.java.aws.config.AwsProfile; import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; -import software.amazon.smithy.java.aws.credentials.chain.CreateResult; +import software.amazon.smithy.java.aws.credentials.chain.ChainSetup; import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; -import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; import software.amazon.smithy.java.context.Context; import software.amazon.smithy.java.core.serde.document.Document; @@ -48,10 +47,6 @@ public final class CredentialProcessHandler implements ChainIdentityProvider { private static final Set FEATURE_IDS = Set.of( new CredentialFeatureId("v"), new CredentialFeatureId("w")); - private static final IdentityResult NO_PROFILE = - IdentityResult.ofError(CredentialProcessHandler.class, "No active profile"); - private static final IdentityResult NOT_FOUND = - IdentityResult.ofError(CredentialProcessHandler.class, "No credential_process in profile"); @Override public String name() { @@ -69,24 +64,20 @@ public Set featureIds() { } @Override - @SuppressWarnings("unchecked") - public CreateResult create(Class identityType, ProviderContext context) { + public void create(Class identityType, ChainSetup setup) { if (identityType != AwsCredentialsIdentity.class) { - return CreateResult.pass(); + return; } - - AwsProfile profile = context.profile(); + AwsProfile profile = setup.profile(); if (profile == null) { - return CreateResult.pass(); + return; } - for (AwsConfigCredentialSource source : profile.credentialSources()) { if (source instanceof AwsConfigCredentialSource.CredentialProcess(String commandLine)) { - return (CreateResult) new CreateResult.PossibleMatch<>(new Resolver(commandLine)); + setup.addResolver(new Resolver(commandLine)); + return; } } - - return CreateResult.pass(); } private record Resolver(String commandLine) implements IdentityResolver { @@ -156,14 +147,12 @@ private static IdentityResult parseOutput(String json) { return IdentityResult.ofError(CredentialProcessHandler.class, "credential_process output has unsupported Version: " + versionNode.asInteger()); } - String accessKeyId = stringMember(doc, "AccessKeyId"); String secretAccessKey = stringMember(doc, "SecretAccessKey"); if (accessKeyId == null || secretAccessKey == null) { return IdentityResult.ofError(CredentialProcessHandler.class, "credential_process output missing required AccessKeyId or SecretAccessKey"); } - String sessionToken = stringMember(doc, "SessionToken"); String accountId = stringMember(doc, "AccountId"); String expirationStr = stringMember(doc, "Expiration"); @@ -175,7 +164,6 @@ private static IdentityResult parseOutput(String json) { LOGGER.warn("credential_process returned unparseable Expiration: {}", expirationStr); } } - return IdentityResult.of(AwsCredentialsIdentity.create( accessKeyId, secretAccessKey, diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SessionKeysHandler.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SessionKeysHandler.java index 65cdeaa37..b005e0569 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SessionKeysHandler.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SessionKeysHandler.java @@ -13,16 +13,16 @@ import software.amazon.smithy.java.aws.config.AwsConfigCredentialSource; import software.amazon.smithy.java.aws.config.AwsProfile; import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; -import software.amazon.smithy.java.aws.credentials.chain.CreateResult; +import software.amazon.smithy.java.aws.credentials.chain.ChainSetup; import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; -import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; import software.amazon.smithy.java.context.Context; /** * Resolves {@link AwsConfigCredentialSource.SessionKeys} from the active profile. - * Re-reads the profile on each call to support live reload after invalidation. + * Re-reads from the setup's profile on each resolution to support live reload. + * Registers as terminal — session keys cannot fail. */ public final class SessionKeysHandler implements ChainIdentityProvider { @@ -48,51 +48,34 @@ public Set featureIds() { } @Override - @SuppressWarnings("unchecked") - public CreateResult create(Class identityType, ProviderContext context) { + public void create(Class identityType, ChainSetup setup) { if (identityType != AwsCredentialsIdentity.class) { - return CreateResult.pass(); + return; } - - AwsProfile profile = context.profile(); + AwsProfile profile = setup.profile(); if (profile == null) { - return CreateResult.pass(); + return; } - for (AwsConfigCredentialSource source : profile.credentialSources()) { - if (source instanceof AwsConfigCredentialSource.SessionKeys) { - return (CreateResult) new CreateResult.UnconditionalMatch<>(new Resolver(context)); - } - } - - return CreateResult.pass(); - } + if (source instanceof AwsConfigCredentialSource.SessionKeys s) { + IdentityResult result = IdentityResult.of( + AwsCredentialsIdentity.create( + s.accessKeyId(), + s.secretAccessKey(), + s.sessionToken(), + null, + s.accountId())); + setup.addTerminalResolver(new IdentityResolver() { + public IdentityResult resolveIdentity(Context c) { + return result; + } - private record Resolver(ProviderContext context) implements IdentityResolver { - @Override - public Class identityType() { - return AwsCredentialsIdentity.class; - } - - @Override - public IdentityResult resolveIdentity(Context ctx) { - AwsProfile profile = context.profile(); - if (profile == null) { - return NO_PROFILE; + public Class identityType() { + return AwsCredentialsIdentity.class; + } + }); + return; } - - for (AwsConfigCredentialSource source : profile.credentialSources()) { - if (source instanceof AwsConfigCredentialSource.SessionKeys(String accessKeyId, String secretAccessKey, String sessionToken, String accountId)) { - return IdentityResult.of(AwsCredentialsIdentity.create( - accessKeyId, - secretAccessKey, - sessionToken, - null, - accountId)); - } - } - - return NOT_FOUND; } } } diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SharedConfigProvider.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SharedConfigProvider.java index 25ea90637..36b1919c1 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SharedConfigProvider.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SharedConfigProvider.java @@ -8,17 +8,17 @@ import software.amazon.smithy.java.auth.api.identity.Identity; import software.amazon.smithy.java.aws.config.AwsProfileFile; import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; -import software.amazon.smithy.java.aws.credentials.chain.CreateResult; +import software.amazon.smithy.java.aws.credentials.chain.ChainSetup; import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; -import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; /** - * Claims the {@link StandardProvider#SHARED_CONFIG} slot. Parses the AWS config/credentials - * files and stores the result on the {@link ProviderContext} for downstream providers. - * Returns {@code null} — it does not itself resolve credentials. + * Claims the {@link software.amazon.smithy.java.aws.credentials.chain.StandardProvider#SHARED_CONFIG} + * slot. Parses the AWS config/credentials files and stores the result on the {@link ChainSetup} + * for downstream providers. Does not register any resolver. */ public final class SharedConfigProvider implements ChainIdentityProvider { + @Override public String name() { return "SharedConfig"; @@ -30,17 +30,16 @@ public OrderingConstraint ordering() { } @Override - public CreateResult create(Class identityType, ProviderContext context) { + public void create(Class identityType, ChainSetup setup) { AwsProfileFile profileFile = AwsProfileFile.loadSilently(); if (profileFile != null) { - context.setProfileFile(profileFile); - String name = context.profileNameOverride(); + setup.setProfileFile(profileFile); + String name = setup.profileNameOverride(); if (name == null) { - context.setProfile(profileFile.activeProfile()); + setup.setProfile(profileFile.activeProfile()); } else { - context.setProfile(profileFile.profile(name)); + setup.setProfile(profileFile.profile(name)); } } - return CreateResult.pass(); } } diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/StaticKeysHandler.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/StaticKeysHandler.java index f163d3d5a..13ba77832 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/StaticKeysHandler.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/StaticKeysHandler.java @@ -13,16 +13,16 @@ import software.amazon.smithy.java.aws.config.AwsConfigCredentialSource; import software.amazon.smithy.java.aws.config.AwsProfile; import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; -import software.amazon.smithy.java.aws.credentials.chain.CreateResult; +import software.amazon.smithy.java.aws.credentials.chain.ChainSetup; import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; -import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; import software.amazon.smithy.java.context.Context; /** * Resolves {@link AwsConfigCredentialSource.StaticKeys} from the active profile. - * Re-reads the profile on each call to support live reload after invalidation. + * Re-reads from the setup's profile on each resolution to support live reload. + * Registers as terminal — static keys cannot fail. */ public final class StaticKeysHandler implements ChainIdentityProvider { @@ -48,52 +48,34 @@ public Set featureIds() { } @Override - @SuppressWarnings("unchecked") - public CreateResult create(Class identityType, ProviderContext context) { + public void create(Class identityType, ChainSetup setup) { if (identityType != AwsCredentialsIdentity.class) { - return CreateResult.pass(); + return; } - - AwsProfile profile = context.profile(); + AwsProfile profile = setup.profile(); if (profile == null) { - return CreateResult.pass(); + return; } - - // Check if this source type exists at assembly time for (AwsConfigCredentialSource source : profile.credentialSources()) { - if (source instanceof AwsConfigCredentialSource.StaticKeys) { - return (CreateResult) new CreateResult.UnconditionalMatch<>(new Resolver(context)); - } - } - - return CreateResult.pass(); - } + if (source instanceof AwsConfigCredentialSource.StaticKeys s) { + IdentityResult result = IdentityResult.of( + AwsCredentialsIdentity.create( + s.accessKeyId(), + s.secretAccessKey(), + null, + null, + s.accountId())); + setup.addTerminalResolver(new IdentityResolver() { + public IdentityResult resolveIdentity(Context c) { + return result; + } - private record Resolver(ProviderContext context) implements IdentityResolver { - @Override - public Class identityType() { - return AwsCredentialsIdentity.class; - } - - @Override - public IdentityResult resolveIdentity(Context ctx) { - AwsProfile profile = context.profile(); - if (profile == null) { - return NO_PROFILE; + public Class identityType() { + return AwsCredentialsIdentity.class; + } + }); + return; } - - for (AwsConfigCredentialSource source : profile.credentialSources()) { - if (source instanceof AwsConfigCredentialSource.StaticKeys(String accessKeyId, String secretAccessKey, String accountId)) { - return IdentityResult.of(AwsCredentialsIdentity.create( - accessKeyId, - secretAccessKey, - null, - null, - accountId)); - } - } - - return NOT_FOUND; } } } diff --git a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java index ebe685d1b..a4f312ea1 100644 --- a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java +++ b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java @@ -167,20 +167,17 @@ private static ChainIdentityProvider registration( IdentityResolver resolver ) { return new ChainIdentityProvider() { - @Override public String name() { return name; } - @Override public OrderingConstraint ordering() { return ordering; } - @Override @SuppressWarnings("unchecked") - public CreateResult create(Class identityType, ProviderContext context) { - return (CreateResult) new CreateResult.PossibleMatch<>(resolver); + public void create(Class identityType, ChainSetup setup) { + setup.addResolver(resolver); } }; } @@ -188,12 +185,10 @@ public CreateResult create(Class identityType, Provid private static IdentityResolver errorResolver(String msg) { IdentityResult result = IdentityResult.ofError(AwsCredentialChainTest.class, msg); return new IdentityResolver<>() { - @Override public IdentityResult resolveIdentity(Context ctx) { return result; } - @Override public Class identityType() { return AwsCredentialsIdentity.class; } @@ -203,12 +198,10 @@ public Class identityType() { private static IdentityResolver staticResolver(String ak, String sk) { IdentityResult result = IdentityResult.of(AwsCredentialsIdentity.create(ak, sk)); return new IdentityResolver<>() { - @Override public IdentityResult resolveIdentity(Context ctx) { return result; } - @Override public Class identityType() { return AwsCredentialsIdentity.class; } diff --git a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java index f64a1a6b4..106963cc2 100644 --- a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java +++ b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java @@ -114,25 +114,21 @@ private static ChainIdentityProvider provider( IdentityResolver resolver ) { return new ChainIdentityProvider() { - @Override public String name() { return name; } - @Override public Set featureIds() { return featureIds; } - @Override public OrderingConstraint ordering() { return new OrderingConstraint.Standard(slot); } - @Override @SuppressWarnings("unchecked") - public CreateResult create(Class identityType, ProviderContext context) { - return (CreateResult) new CreateResult.PossibleMatch<>(resolver); + public void create(Class identityType, ChainSetup setup) { + setup.addResolver(resolver); } }; } @@ -140,12 +136,10 @@ public CreateResult create(Class identityType, Provid private static IdentityResolver staticResolver(String ak, String sk) { IdentityResult result = IdentityResult.of(AwsCredentialsIdentity.create(ak, sk)); return new IdentityResolver<>() { - @Override public IdentityResult resolveIdentity(Context ctx) { return result; } - @Override public Class identityType() { return AwsCredentialsIdentity.class; } @@ -155,12 +149,10 @@ public Class identityType() { private static IdentityResolver errorResolver(String msg) { IdentityResult result = IdentityResult.ofError(FeatureIdTest.class, msg); return new IdentityResolver<>() { - @Override public IdentityResult resolveIdentity(Context ctx) { return result; } - @Override public Class identityType() { return AwsCredentialsIdentity.class; } diff --git a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandlerTest.java b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandlerTest.java index 93f472880..ff494d04f 100644 --- a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandlerTest.java +++ b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandlerTest.java @@ -21,8 +21,7 @@ import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; import software.amazon.smithy.java.aws.config.AwsConfigCredentialSource; import software.amazon.smithy.java.aws.config.AwsProfileFile; -import software.amazon.smithy.java.aws.credentials.chain.CreateResult; -import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; +import software.amazon.smithy.java.aws.credentials.chain.ChainSetup; import software.amazon.smithy.java.context.Context; class CredentialProcessHandlerTest { @@ -127,24 +126,24 @@ private IdentityResult createFromProfileResult(AwsConfig return null; } var handler = new CredentialProcessHandler(); - var ctx = new ProviderContext(null); + var setup = new ChainSetup(null); try { Path configPath = Files.createTempFile("aws-config", ".ini"); Files.writeString(configPath, "[default]\ncredential_process=" + cp.commandLine() + "\n"); var file = AwsProfileFile.builder().configFile(configPath).credentialsFile(null).build(); - ctx.setProfileFile(file); - ctx.setProfile(file.activeProfile()); + setup.setProfileFile(file); + setup.setProfile(file.activeProfile()); } catch (IOException e) { throw new RuntimeException(e); } - var result = handler.create(AwsCredentialsIdentity.class, ctx); - if (!(result instanceof CreateResult.PossibleMatch m)) { + setup.setCurrentProvider(handler); + handler.create(AwsCredentialsIdentity.class, setup); + var resolvers = setup.resolvers(); + if (resolvers.isEmpty()) { return null; } - IdentityResolver resolver = m.resolver(); - if (resolver == null) { - return null; - } - return resolver.resolveIdentity(Context.empty()); + @SuppressWarnings("unchecked") + var r = (IdentityResolver) resolvers.getFirst().resolver(); + return r.resolveIdentity(Context.empty()); } } diff --git a/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java b/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java index e67ef9710..891e6ddd7 100644 --- a/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java +++ b/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java @@ -19,10 +19,9 @@ import software.amazon.smithy.java.aws.config.AwsProfile; import software.amazon.smithy.java.aws.config.AwsProfileFile; import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; -import software.amazon.smithy.java.aws.credentials.chain.CreateResult; +import software.amazon.smithy.java.aws.credentials.chain.ChainSetup; import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; -import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; import software.amazon.smithy.java.context.Context; import software.amazon.smithy.java.core.serde.document.Document; @@ -49,25 +48,24 @@ public String name() { } @Override - public Set featureIds() { - return FEATURE_IDS; + public OrderingConstraint ordering() { + return new OrderingConstraint.Standard(StandardProvider.EC2_INSTANCE_METADATA); } @Override - public OrderingConstraint ordering() { - return new OrderingConstraint.Standard(StandardProvider.EC2_INSTANCE_METADATA); + public Set featureIds() { + return FEATURE_IDS; } @Override - @SuppressWarnings("unchecked") - public CreateResult create(Class identityType, ProviderContext context) { + public void create(Class identityType, ChainSetup setup) { if (identityType != AwsCredentialsIdentity.class) { - return CreateResult.pass(); + return; } - AwsProfileFile profileFile = context.profileFile(); + AwsProfileFile profileFile = setup.profileFile(); if (isDisabled(profileFile)) { - return CreateResult.pass(); + return; } URI endpoint = resolveEndpoint(); @@ -75,8 +73,8 @@ public CreateResult create(Class identityType, Provid ImdsClient client = new ImdsClient(endpoint); AwsCredentialsResolver delegate = ctx -> fetchAndParse(client, profileName); - return (CreateResult) new CreateResult.PossibleMatch<>(CachingIdentityResolver.builder(delegate) - .executor(context.executor()) + setup.addResolver(CachingIdentityResolver.builder(delegate) + .executor(setup.executor()) .allowExpiredCredentials(true) .build()); } diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java index eedc6400c..b84fd8cb7 100644 --- a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/EnvironmentCredentialProvider.java @@ -9,10 +9,9 @@ import software.amazon.smithy.java.auth.api.identity.Identity; import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; -import software.amazon.smithy.java.aws.credentials.chain.CreateResult; +import software.amazon.smithy.java.aws.credentials.chain.ChainSetup; import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; -import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; public final class EnvironmentCredentialProvider implements ChainIdentityProvider { @@ -25,22 +24,19 @@ public String name() { } @Override - public Set featureIds() { - return FEATURE_IDS; + public OrderingConstraint ordering() { + return new OrderingConstraint.Standard(StandardProvider.ENVIRONMENT); } @Override - public OrderingConstraint ordering() { - return new OrderingConstraint.Standard(StandardProvider.ENVIRONMENT); + public Set featureIds() { + return FEATURE_IDS; } @Override - @SuppressWarnings("unchecked") - public CreateResult create(Class identityType, ProviderContext context) { + public void create(Class identityType, ChainSetup setup) { if (identityType == AwsCredentialsIdentity.class) { - return (CreateResult< - I>) new CreateResult.UnconditionalMatch<>(EnvironmentVariableIdentityResolver.INSTANCE); + setup.addTerminalResolver(EnvironmentVariableIdentityResolver.INSTANCE); } - return CreateResult.pass(); } } diff --git a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java index 64acbb538..a25190eba 100644 --- a/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java +++ b/aws/client/aws-client-core/src/main/java/software/amazon/smithy/java/aws/client/core/identity/SystemPropertiesCredentialProvider.java @@ -9,10 +9,9 @@ import software.amazon.smithy.java.auth.api.identity.Identity; import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; -import software.amazon.smithy.java.aws.credentials.chain.CreateResult; +import software.amazon.smithy.java.aws.credentials.chain.ChainSetup; import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; -import software.amazon.smithy.java.aws.credentials.chain.ProviderContext; import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; public final class SystemPropertiesCredentialProvider implements ChainIdentityProvider { @@ -25,21 +24,19 @@ public String name() { } @Override - public Set featureIds() { - return FEATURE_IDS; + public OrderingConstraint ordering() { + return new OrderingConstraint.Standard(StandardProvider.JAVA_SYSTEM_PROPERTIES); } @Override - public OrderingConstraint ordering() { - return new OrderingConstraint.Standard(StandardProvider.JAVA_SYSTEM_PROPERTIES); + public Set featureIds() { + return FEATURE_IDS; } @Override - @SuppressWarnings("unchecked") - public CreateResult create(Class identityType, ProviderContext context) { + public void create(Class identityType, ChainSetup setup) { if (identityType == AwsCredentialsIdentity.class) { - return (CreateResult) new CreateResult.UnconditionalMatch<>(SystemPropertiesIdentityResolver.INSTANCE); + setup.addTerminalResolver(SystemPropertiesIdentityResolver.INSTANCE); } - return CreateResult.pass(); } } From 81ec35e7ef0bf90d1b587d88ab8e079c9d823898 Mon Sep 17 00:00:00 2001 From: Michael Dowling Date: Thu, 14 May 2026 17:30:48 -0500 Subject: [PATCH 13/13] Add tests; rename create to setup; cleanup --- .../chain/ChainIdentityProvider.java | 4 +- .../aws/credentials/chain/ChainSetup.java | 210 +++++++++++++++--- .../credentials/chain/CredentialChain.java | 12 +- .../credentials/chain/StandardProvider.java | 9 +- .../config/CredentialProcessHandler.java | 2 +- .../chain/config/SessionKeysHandler.java | 2 +- .../chain/config/SharedConfigProvider.java | 2 +- .../chain/config/StaticKeysHandler.java | 2 +- .../chain/AwsCredentialChainTest.java | 3 +- .../aws/credentials/chain/FeatureIdTest.java | 3 +- .../config/CredentialProcessHandlerTest.java | 4 +- .../imds/ImdsCredentialProvider.java | 2 +- aws/aws-credentials-sts/build.gradle.kts | 27 +++ .../sts/EnvWebIdentityProvider.java | 59 +++++ .../sts/ProfileAssumeRoleProvider.java | 59 +++++ .../sts/ProfileWebIdentityProvider.java | 56 +++++ .../sts/StsAssumeRoleResolver.java | 166 ++++++++++++++ .../aws/credentials/sts/StsClientFactory.java | 69 ++++++ .../sts/StsWebIdentityResolver.java | 99 +++++++++ ...ws.credentials.chain.ChainIdentityProvider | 3 + .../sts/EnvWebIdentityProviderTest.java | 87 ++++++++ .../sts/ProfileAssumeRoleProviderTest.java | 131 +++++++++++ .../sts/ProfileWebIdentityProviderTest.java | 80 +++++++ .../sts/StsAssumeRoleResolverTest.java | 194 ++++++++++++++++ .../sts/StsResponseParsingTest.java | 69 ++++++ .../sts/StsWebIdentityResolverTest.java | 52 +++++ .../EnvironmentCredentialProvider.java | 2 +- .../SystemPropertiesCredentialProvider.java | 2 +- settings.gradle.kts | 1 + 29 files changed, 1353 insertions(+), 58 deletions(-) create mode 100644 aws/aws-credentials-sts/build.gradle.kts create mode 100644 aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/EnvWebIdentityProvider.java create mode 100644 aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/ProfileAssumeRoleProvider.java create mode 100644 aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/ProfileWebIdentityProvider.java create mode 100644 aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/StsAssumeRoleResolver.java create mode 100644 aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/StsClientFactory.java create mode 100644 aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/StsWebIdentityResolver.java create mode 100644 aws/aws-credentials-sts/src/main/resources/META-INF/services/software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider create mode 100644 aws/aws-credentials-sts/src/test/java/software/amazon/smithy/java/aws/credentials/sts/EnvWebIdentityProviderTest.java create mode 100644 aws/aws-credentials-sts/src/test/java/software/amazon/smithy/java/aws/credentials/sts/ProfileAssumeRoleProviderTest.java create mode 100644 aws/aws-credentials-sts/src/test/java/software/amazon/smithy/java/aws/credentials/sts/ProfileWebIdentityProviderTest.java create mode 100644 aws/aws-credentials-sts/src/test/java/software/amazon/smithy/java/aws/credentials/sts/StsAssumeRoleResolverTest.java create mode 100644 aws/aws-credentials-sts/src/test/java/software/amazon/smithy/java/aws/credentials/sts/StsResponseParsingTest.java create mode 100644 aws/aws-credentials-sts/src/test/java/software/amazon/smithy/java/aws/credentials/sts/StsWebIdentityResolverTest.java diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainIdentityProvider.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainIdentityProvider.java index 2808ebda4..727df886c 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainIdentityProvider.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainIdentityProvider.java @@ -12,7 +12,7 @@ * SPI for registering an identity provider into a credential/token chain. * *

      Implementations are discovered via the language's plugin mechanism (e.g., {@code ServiceLoader} - * in Java) and sorted by {@link #ordering()} before {@link #create} is called. A provider + * in Java) and sorted by {@link #ordering()} before {@link #setup} is called. A provider * registers its resolver by calling {@link ChainSetup#addResolver} or * {@link ChainSetup#addTerminalResolver} from within {@code create()}. */ @@ -47,5 +47,5 @@ default Set featureIds() { * @param identityType the identity class the chain is resolving. * @param setup the chain setup context. */ - void create(Class identityType, ChainSetup setup); + void setup(Class identityType, ChainSetup setup); } diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainSetup.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainSetup.java index 2a3fc3132..e8a1f7906 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainSetup.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/ChainSetup.java @@ -9,106 +9,256 @@ import java.util.List; import java.util.Set; import java.util.concurrent.ScheduledExecutorService; -import software.amazon.smithy.java.auth.api.identity.Identity; +import java.util.function.Function; import software.amazon.smithy.java.auth.api.identity.IdentityResolver; import software.amazon.smithy.java.aws.config.AwsProfile; import software.amazon.smithy.java.aws.config.AwsProfileFile; import software.amazon.smithy.java.context.Context; +import software.amazon.smithy.utils.SmithyInternalApi; /** - * Mutable assembly context passed to each {@link ChainIdentityProvider#create} during chain - * construction. Providers use this to read shared state and register resolvers. + * Mutable assembly context passed to each {@link ChainIdentityProvider#setup} during + * credential chain construction. * - *

      When {@link #addTerminalResolver} is called, assembly stops — no further providers are invoked. + *

      Providers use this to: + *

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

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

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

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

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

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

      This method is public to support unit testing of individual providers in + * isolation. Production code should not call this directly. + * + * @param provider the provider currently being assembled. + */ + @SmithyInternalApi public void setCurrentProvider(ChainIdentityProvider provider) { this.currentProvider = provider; } + /** + * Returns the list of resolvers registered during assembly, in the order they were added. + * + * @return the ordered list of named resolvers. + */ + public List resolvers() { + return resolvers; + } + boolean isTerminal() { return terminal; } - public List resolvers() { - return resolvers; - } + /** + * A resolver paired with the name and feature IDs of the provider that registered it. + * + * @param name the canonical name of the provider that registered this resolver. + * @param featureIds the feature IDs to emit on successful resolution. + * @param resolver the identity resolver. + */ + public record NamedResolver(String name, Set featureIds, IdentityResolver resolver) {} + + /** + * Builder for {@link ChainSetup}. + */ + public static final class Builder { + private ScheduledExecutorService executor; + private String profileNameOverride; + private Function envFn = System::getenv; - public record NamedResolver( - String name, - Set featureIds, - IdentityResolver resolver) {} + private Builder() {} + + /** + * Sets the shared executor for background credential refresh. + * + * @param executor the scheduled executor. + * @return this builder. + */ + public Builder executor(ScheduledExecutorService executor) { + this.executor = executor; + return this; + } + + /** + * Sets the profile name override. When set, the {@code SHARED_CONFIG} provider + * uses this name instead of resolving from {@code AWS_PROFILE} or system properties. + * + * @param profileNameOverride the profile name to use. + * @return this builder. + */ + public Builder profileNameOverride(String profileNameOverride) { + this.profileNameOverride = profileNameOverride; + return this; + } + + /** + * Sets the function used to resolve environment variables. Defaults to + * {@link System#getenv(String)}. Override in tests for isolation. + * + * @param envFn the environment variable lookup function. + * @return this builder. + */ + public Builder env(Function envFn) { + this.envFn = envFn; + return this; + } + + /** + * Builds the {@link ChainSetup}. + * + * @return the constructed setup. + */ + public ChainSetup build() { + return new ChainSetup(this); + } + } } diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialChain.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialChain.java index ebbf48087..e437184c2 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialChain.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/CredentialChain.java @@ -105,17 +105,17 @@ static CredentialChain assemble( List sorted = sortByOrdering(registrations); // Call create() on each provider in sorted order. - ChainSetup setup = new ChainSetup(executor); + ChainSetup setup = ChainSetup.builder().executor(executor).build(); for (ChainIdentityProvider provider : sorted) { setup.setCurrentProvider(provider); - provider.create(identityType, setup); + provider.setup(identityType, setup); if (setup.isTerminal()) { break; } } - List ordered = setup.resolvers(); + var ordered = setup.resolvers(); if (LOGGER.isDebugEnabled()) { LOGGER.debug("Assembled credential chain: {}", @@ -207,6 +207,7 @@ private static void warnDetectedButUnclaimed(Set claimed) { } @Override + @SuppressWarnings("unchecked") public IdentityResult resolveIdentity(Context requestProperties) { if (resolvers.isEmpty()) { return IdentityResult.ofError(getClass(), @@ -218,8 +219,7 @@ public IdentityResult resolveIdentity(Context requestProperties) { List errors = new ArrayList<>(); for (var nr : resolvers) { - @SuppressWarnings("unchecked") - var result = (IdentityResult) nr.resolver().resolveIdentity(requestProperties); + var result = nr.resolver().resolveIdentity(requestProperties); if (result.identity() != null) { if (!nr.featureIds().isEmpty()) { var ids = requestProperties.get(CallContext.FEATURE_IDS); @@ -227,7 +227,7 @@ public IdentityResult resolveIdentity(Context requestProperties) { ids.addAll(nr.featureIds()); } } - return result; + return (IdentityResult) result; } errors.add(nr.name()); errors.add(result.error()); diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/StandardProvider.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/StandardProvider.java index b71592ac3..9745cbcc4 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/StandardProvider.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/StandardProvider.java @@ -58,11 +58,11 @@ public boolean isDetected() { /** * Parses AWS shared config/credentials files and stores the result on the - * {@link ProviderContext} for downstream providers. + * {@link ChainSetup} for downstream providers. * *

      This provider does not itself resolve credentials — it returns {@code null} from * {@code create()}. Its purpose is to make the parsed profile available via - * {@link ProviderContext#profile()} for all subsequent profile-based slots. + * {@link ChainSetup#profile()} for all subsequent profile-based slots. */ SHARED_CONFIG(null) { @Override @@ -76,11 +76,6 @@ public boolean isDetected() { } }, - /** - * Profile-based web identity token ({@code web_identity_token_file} + {@code role_arn}). - * - *

      Requires the STS module. Reads the active profile from {@link ProviderContext#profile()}. - */ /** * Profile-based static keys ({@code aws_access_key_id} + {@code aws_secret_access_key}). * diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandler.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandler.java index 708e5dce9..6c2284b09 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandler.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandler.java @@ -64,7 +64,7 @@ public Set featureIds() { } @Override - public void create(Class identityType, ChainSetup setup) { + public void setup(Class identityType, ChainSetup setup) { if (identityType != AwsCredentialsIdentity.class) { return; } diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SessionKeysHandler.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SessionKeysHandler.java index b005e0569..83dbf9b97 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SessionKeysHandler.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SessionKeysHandler.java @@ -48,7 +48,7 @@ public Set featureIds() { } @Override - public void create(Class identityType, ChainSetup setup) { + public void setup(Class identityType, ChainSetup setup) { if (identityType != AwsCredentialsIdentity.class) { return; } diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SharedConfigProvider.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SharedConfigProvider.java index 36b1919c1..13a695d3d 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SharedConfigProvider.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/SharedConfigProvider.java @@ -30,7 +30,7 @@ public OrderingConstraint ordering() { } @Override - public void create(Class identityType, ChainSetup setup) { + public void setup(Class identityType, ChainSetup setup) { AwsProfileFile profileFile = AwsProfileFile.loadSilently(); if (profileFile != null) { setup.setProfileFile(profileFile); diff --git a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/StaticKeysHandler.java b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/StaticKeysHandler.java index 13ba77832..d3b9267ea 100644 --- a/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/StaticKeysHandler.java +++ b/aws/aws-credential-chain/src/main/java/software/amazon/smithy/java/aws/credentials/chain/config/StaticKeysHandler.java @@ -48,7 +48,7 @@ public Set featureIds() { } @Override - public void create(Class identityType, ChainSetup setup) { + public void setup(Class identityType, ChainSetup setup) { if (identityType != AwsCredentialsIdentity.class) { return; } diff --git a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java index a4f312ea1..d65cdaa9b 100644 --- a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java +++ b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/AwsCredentialChainTest.java @@ -175,8 +175,7 @@ public OrderingConstraint ordering() { return ordering; } - @SuppressWarnings("unchecked") - public void create(Class identityType, ChainSetup setup) { + public void setup(Class identityType, ChainSetup setup) { setup.addResolver(resolver); } }; diff --git a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java index 106963cc2..4aa61d701 100644 --- a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java +++ b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/FeatureIdTest.java @@ -126,8 +126,7 @@ public OrderingConstraint ordering() { return new OrderingConstraint.Standard(slot); } - @SuppressWarnings("unchecked") - public void create(Class identityType, ChainSetup setup) { + public void setup(Class identityType, ChainSetup setup) { setup.addResolver(resolver); } }; diff --git a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandlerTest.java b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandlerTest.java index ff494d04f..264dbd3ea 100644 --- a/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandlerTest.java +++ b/aws/aws-credential-chain/src/test/java/software/amazon/smithy/java/aws/credentials/chain/config/CredentialProcessHandlerTest.java @@ -126,7 +126,7 @@ private IdentityResult createFromProfileResult(AwsConfig return null; } var handler = new CredentialProcessHandler(); - var setup = new ChainSetup(null); + var setup = ChainSetup.builder().build(); try { Path configPath = Files.createTempFile("aws-config", ".ini"); Files.writeString(configPath, "[default]\ncredential_process=" + cp.commandLine() + "\n"); @@ -137,7 +137,7 @@ private IdentityResult createFromProfileResult(AwsConfig throw new RuntimeException(e); } setup.setCurrentProvider(handler); - handler.create(AwsCredentialsIdentity.class, setup); + handler.setup(AwsCredentialsIdentity.class, setup); var resolvers = setup.resolvers(); if (resolvers.isEmpty()) { return null; diff --git a/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java b/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java index 891e6ddd7..ab4f5b348 100644 --- a/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java +++ b/aws/aws-credentials-imds/src/main/java/software/amazon/smithy/java/aws/credentials/imds/ImdsCredentialProvider.java @@ -58,7 +58,7 @@ public Set featureIds() { } @Override - public void create(Class identityType, ChainSetup setup) { + public void setup(Class identityType, ChainSetup setup) { if (identityType != AwsCredentialsIdentity.class) { return; } diff --git a/aws/aws-credentials-sts/build.gradle.kts b/aws/aws-credentials-sts/build.gradle.kts new file mode 100644 index 000000000..fc3dba78a --- /dev/null +++ b/aws/aws-credentials-sts/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + id("smithy-java.module-conventions") +} + +description = "STS-based credential providers (assume-role, web identity token)." + +extra["displayName"] = "Smithy :: Java :: AWS :: Credentials :: STS" +extra["moduleName"] = "software.amazon.smithy.java.aws.credentials.sts" + +dependencies { + implementation(project(":aws:aws-credential-chain")) + implementation(project(":aws:aws-config")) + implementation(project(":aws:aws-auth-api")) + implementation(project(":aws:aws-credentials-imds")) + implementation(project(":auth-api")) + implementation(project(":client:client-core")) + implementation(project(":client:dynamic-client")) + implementation(project(":client:client-rulesengine")) + implementation(project(":aws:client:aws-client-rulesengine")) + implementation(project(":aws:client:aws-client-awsjson")) + implementation(project(":aws:client:aws-client-awsquery")) + implementation(project(":codecs:json-codec", configuration = "shadow")) + implementation(project(":logging")) + implementation("software.amazon.api.models:sts:1.0.5") + testImplementation(project(":client:client-mock-plugin")) + testImplementation(project(":http:http-api")) +} diff --git a/aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/EnvWebIdentityProvider.java b/aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/EnvWebIdentityProvider.java new file mode 100644 index 000000000..d26c0f268 --- /dev/null +++ b/aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/EnvWebIdentityProvider.java @@ -0,0 +1,59 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.sts; + +import java.util.Set; +import software.amazon.smithy.java.auth.api.identity.Identity; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.config.AwsConfigCredentialSource; +import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; +import software.amazon.smithy.java.aws.credentials.chain.ChainSetup; +import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; +import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; +import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; + +/** + * Resolves credentials via STS AssumeRoleWithWebIdentity using environment variables + * ({@code AWS_WEB_IDENTITY_TOKEN_FILE} + {@code AWS_ROLE_ARN}). + */ +public final class EnvWebIdentityProvider implements ChainIdentityProvider { + + private static final Set FEATURE_IDS = Set.of( + new CredentialFeatureId("h"), + new CredentialFeatureId("k")); + + @Override + public String name() { + return "EnvWebIdentity"; + } + + @Override + public OrderingConstraint ordering() { + return new OrderingConstraint.Standard(StandardProvider.WEB_IDENTITY_TOKEN_ENV); + } + + @Override + public Set featureIds() { + return FEATURE_IDS; + } + + @Override + public void setup(Class identityType, ChainSetup setup) { + if (identityType != AwsCredentialsIdentity.class) { + return; + } + + String tokenFile = setup.getenv("AWS_WEB_IDENTITY_TOKEN_FILE"); + if (tokenFile != null) { + String roleArn = setup.getenv("AWS_ROLE_ARN"); + if (roleArn != null) { + String sessionName = setup.getenv("AWS_ROLE_SESSION_NAME"); + var wit = new AwsConfigCredentialSource.WebIdentityToken(roleArn, tokenFile, sessionName, null); + setup.addTerminalResolver(new StsWebIdentityResolver(wit, StsClientFactory.createNoAuth())); + } + } + } +} diff --git a/aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/ProfileAssumeRoleProvider.java b/aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/ProfileAssumeRoleProvider.java new file mode 100644 index 000000000..37b103dcf --- /dev/null +++ b/aws/aws-credentials-sts/src/main/java/software/amazon/smithy/java/aws/credentials/sts/ProfileAssumeRoleProvider.java @@ -0,0 +1,59 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.aws.credentials.sts; + +import java.util.Set; +import software.amazon.smithy.java.auth.api.identity.Identity; +import software.amazon.smithy.java.aws.auth.api.identity.AwsCredentialsIdentity; +import software.amazon.smithy.java.aws.config.AwsConfigCredentialSource; +import software.amazon.smithy.java.aws.credentials.chain.ChainIdentityProvider; +import software.amazon.smithy.java.aws.credentials.chain.ChainSetup; +import software.amazon.smithy.java.aws.credentials.chain.CredentialFeatureId; +import software.amazon.smithy.java.aws.credentials.chain.OrderingConstraint; +import software.amazon.smithy.java.aws.credentials.chain.StandardProvider; + +/** + * Resolves credentials by assuming an IAM role via STS, configured from + * {@code role_arn} + {@code source_profile} or {@code credential_source} + * in the active AWS profile. + * + *

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

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

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

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