diff --git a/aws-runtime/aws-config/api/aws-config.api b/aws-runtime/aws-config/api/aws-config.api index 7a2d16aa779..ce35a2d9ef5 100644 --- a/aws-runtime/aws-config/api/aws-config.api +++ b/aws-runtime/aws-config/api/aws-config.api @@ -77,8 +77,6 @@ public final class aws/sdk/kotlin/runtime/auth/credentials/EnvironmentCredential public final class aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProvider : aws/smithy/kotlin/runtime/auth/awscredentials/CloseableCredentialsProvider { public fun ()V - public fun (Ljava/lang/String;Laws/sdk/kotlin/runtime/config/imds/InstanceMetadataProvider;Laws/smithy/kotlin/runtime/util/PlatformProvider;)V - public synthetic fun (Ljava/lang/String;Laws/sdk/kotlin/runtime/config/imds/InstanceMetadataProvider;Laws/smithy/kotlin/runtime/util/PlatformProvider;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun (Ljava/lang/String;Lkotlin/Lazy;Laws/smithy/kotlin/runtime/util/PlatformEnvironProvider;Laws/smithy/kotlin/runtime/time/Clock;)V public synthetic fun (Ljava/lang/String;Lkotlin/Lazy;Laws/smithy/kotlin/runtime/util/PlatformEnvironProvider;Laws/smithy/kotlin/runtime/time/Clock;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun close ()V @@ -280,7 +278,6 @@ public final class aws/sdk/kotlin/runtime/config/AwsSdkSetting { public final fun getAwsContainerCredentialsFullUri ()Laws/smithy/kotlin/runtime/config/EnvironmentSetting; public final fun getAwsContainerCredentialsRelativeUri ()Laws/smithy/kotlin/runtime/config/EnvironmentSetting; public final fun getAwsDisableRequestCompression ()Laws/smithy/kotlin/runtime/config/EnvironmentSetting; - public final fun getAwsEc2InstanceProfileName ()Laws/smithy/kotlin/runtime/config/EnvironmentSetting; public final fun getAwsEc2MetadataDisabled ()Laws/smithy/kotlin/runtime/config/EnvironmentSetting; public final fun getAwsEc2MetadataServiceEndpoint ()Laws/smithy/kotlin/runtime/config/EnvironmentSetting; public final fun getAwsEc2MetadataServiceEndpointMode ()Laws/smithy/kotlin/runtime/config/EnvironmentSetting; @@ -429,13 +426,6 @@ public final class aws/sdk/kotlin/runtime/config/imds/ImdsClient$Companion { public final fun invoke (Lkotlin/jvm/functions/Function1;)Laws/sdk/kotlin/runtime/config/imds/ImdsClient; } -public final class aws/sdk/kotlin/runtime/config/imds/ImdsResolversKt { - public static final fun resolveDisableEc2Metadata (Laws/smithy/kotlin/runtime/util/PlatformProvider;Laws/smithy/kotlin/runtime/util/LazyAsyncValue;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun resolveDisableEc2Metadata$default (Laws/smithy/kotlin/runtime/util/PlatformProvider;Laws/smithy/kotlin/runtime/util/LazyAsyncValue;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; - public static final fun resolveEc2InstanceProfileName (Laws/smithy/kotlin/runtime/util/PlatformProvider;Laws/smithy/kotlin/runtime/util/LazyAsyncValue;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; - public static synthetic fun resolveEc2InstanceProfileName$default (Laws/smithy/kotlin/runtime/util/PlatformProvider;Laws/smithy/kotlin/runtime/util/LazyAsyncValue;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; -} - public abstract interface class aws/sdk/kotlin/runtime/config/imds/InstanceMetadataProvider : java/io/Closeable { public abstract fun get (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } @@ -537,8 +527,6 @@ public final class aws/sdk/kotlin/runtime/config/profile/AwsProfileKt { public static synthetic fun getBooleanOrNull$default (Laws/sdk/kotlin/runtime/config/profile/ConfigSection;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Ljava/lang/Boolean; public static final fun getCredentialProcess (Laws/sdk/kotlin/runtime/config/profile/ConfigSection;)Ljava/lang/String; public static final fun getDisableRequestCompression (Laws/sdk/kotlin/runtime/config/profile/ConfigSection;)Ljava/lang/Boolean; - public static final fun getEc2InstanceProfileName (Laws/sdk/kotlin/runtime/config/profile/ConfigSection;)Ljava/lang/String; - public static final fun getEc2MetadataDisabled (Laws/sdk/kotlin/runtime/config/profile/ConfigSection;)Ljava/lang/Boolean; public static final fun getEndpointDiscoveryEnabled (Laws/sdk/kotlin/runtime/config/profile/ConfigSection;)Ljava/lang/Boolean; public static final fun getEndpointUrl (Laws/sdk/kotlin/runtime/config/profile/ConfigSection;)Laws/smithy/kotlin/runtime/net/url/Url; public static final fun getIgnoreEndpointUrls (Laws/sdk/kotlin/runtime/config/profile/ConfigSection;)Ljava/lang/Boolean; diff --git a/aws-runtime/aws-config/build.gradle.kts b/aws-runtime/aws-config/build.gradle.kts index 89b6df71884..3d3759115d8 100644 --- a/aws-runtime/aws-config/build.gradle.kts +++ b/aws-runtime/aws-config/build.gradle.kts @@ -8,7 +8,6 @@ import aws.sdk.kotlin.gradle.codegen.smithyKotlinProjectionSrcDir plugins { alias(libs.plugins.aws.kotlin.repo.tools.smithybuild) - alias(libs.plugins.kotlinx.serialization) } description = "Support for AWS configuration" @@ -53,7 +52,6 @@ kotlin { implementation(libs.kotlinx.coroutines.test) implementation(libs.smithy.kotlin.http.test) implementation(libs.kotlinx.serialization.json) - implementation(libs.kotest.framework.datatest) } } jvmTest { diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/DefaultChainCredentialsProvider.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/DefaultChainCredentialsProvider.kt index 0b98da26e86..5b849ec0a35 100644 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/DefaultChainCredentialsProvider.kt +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/DefaultChainCredentialsProvider.kt @@ -41,7 +41,7 @@ import aws.smithy.kotlin.runtime.util.PlatformProvider * @param region the region to make credentials requests to. * @return the newly-constructed credentials provider */ -public class DefaultChainCredentialsProvider( +public class DefaultChainCredentialsProvider constructor( public val profileName: String? = null, public val platformProvider: PlatformProvider = PlatformProvider.System, httpClient: HttpClientEngine? = null, @@ -51,11 +51,6 @@ public class DefaultChainCredentialsProvider( private val manageEngine = httpClient == null private val engine = httpClient ?: DefaultHttpEngine() - private val imdsClient = ImdsClient { - platformProvider = this@DefaultChainCredentialsProvider.platformProvider - engine = this@DefaultChainCredentialsProvider.engine - } - private val chain = CredentialsProviderChain( SystemPropertyCredentialsProvider(platformProvider::getProperty), EnvironmentCredentialsProvider(platformProvider::getenv), @@ -64,7 +59,12 @@ public class DefaultChainCredentialsProvider( ProfileCredentialsProvider(profileName = profileName, platformProvider = platformProvider, httpClient = engine, region = region), EcsCredentialsProvider(platformProvider, engine), ImdsCredentialsProvider( - client = imdsClient, + client = lazy { + ImdsClient { + platformProvider = this@DefaultChainCredentialsProvider.platformProvider + engine = this@DefaultChainCredentialsProvider.engine + } + }, platformProvider = platformProvider, ), ) @@ -75,7 +75,6 @@ public class DefaultChainCredentialsProvider( override fun close() { provider.close() - imdsClient.close() if (manageEngine) { engine.closeIfCloseable() } diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProvider.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProvider.kt index f4794ba130a..495e3d810d6 100644 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProvider.kt +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProvider.kt @@ -5,232 +5,150 @@ package aws.sdk.kotlin.runtime.auth.credentials -import aws.sdk.kotlin.runtime.auth.credentials.internal.credentials -import aws.sdk.kotlin.runtime.config.imds.* +import aws.sdk.kotlin.runtime.config.AwsSdkSetting +import aws.sdk.kotlin.runtime.config.imds.EC2MetadataError +import aws.sdk.kotlin.runtime.config.imds.ImdsClient +import aws.sdk.kotlin.runtime.config.imds.InstanceMetadataProvider import aws.sdk.kotlin.runtime.http.interceptors.businessmetrics.AwsBusinessMetric import aws.sdk.kotlin.runtime.http.interceptors.businessmetrics.withBusinessMetric import aws.smithy.kotlin.runtime.auth.awscredentials.* import aws.smithy.kotlin.runtime.collections.Attributes +import aws.smithy.kotlin.runtime.config.resolve import aws.smithy.kotlin.runtime.http.HttpStatusCode import aws.smithy.kotlin.runtime.io.IOException import aws.smithy.kotlin.runtime.serde.json.JsonDeserializer import aws.smithy.kotlin.runtime.telemetry.logging.info +import aws.smithy.kotlin.runtime.telemetry.logging.warn import aws.smithy.kotlin.runtime.time.Clock +import aws.smithy.kotlin.runtime.time.Instant import aws.smithy.kotlin.runtime.util.PlatformEnvironProvider import aws.smithy.kotlin.runtime.util.PlatformProvider -import aws.smithy.kotlin.runtime.util.SingleFlightGroup -import aws.smithy.kotlin.runtime.util.asyncLazy +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlin.coroutines.coroutineContext +import kotlin.time.Duration.Companion.seconds +private const val CREDENTIALS_BASE_PATH: String = "/latest/meta-data/iam/security-credentials/" private const val CODE_ASSUME_ROLE_UNAUTHORIZED_ACCESS: String = "AssumeRoleUnauthorizedAccess" private const val PROVIDER_NAME = "IMDSv2" /** * [CredentialsProvider] that uses EC2 instance metadata service (IMDS) to provide credentials information. - * This provider requires that the EC2 instance has an - * [instance profile](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html#ec2-instance-profile) + * This provider requires that the EC2 instance has an [instance profile](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html#ec2-instance-profile) * configured. - * @param instanceProfileName overrides the instance profile name. When set, this provider skips querying IMDS for the - * name of the active profile. - * @param client a preconfigured IMDS client with which to retrieve instance metadata. If an instance is passed, the - * caller is responsible for closing it. If no instance is passed, a default instance is created and will be closed when - * this credentials provider is closed. - * @param platformProvider a platform provider used for env vars and system properties + * + * See [EC2 IAM Roles](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html) for more + * information. + * + * @param profileOverride override the instance profile name. When retrieving credentials, a call must first be made to + * `/latest/meta-data/iam/security-credentials/`. This returns the instance profile used. If + * [profileOverride] is set, the initial call to retrieve the profile is skipped and the provided value is used instead. + * @param client the IMDS client to use to resolve credentials information with. This provider takes ownership over + * the lifetime of the given [ImdsClient] and will close it when the provider is closed. + * @param platformProvider the [PlatformEnvironProvider] instance */ public class ImdsCredentialsProvider( - instanceProfileName: String? = null, - client: InstanceMetadataProvider? = null, - platformProvider: PlatformProvider = PlatformProvider.System, + public val profileOverride: String? = null, + public val client: Lazy = lazy { ImdsClient() }, + public val platformProvider: PlatformEnvironProvider = PlatformProvider.System, + private val clock: Clock = Clock.System, ) : CloseableCredentialsProvider { - - @Deprecated("This constructor supports parameters which are no longer used in the implementation. It will be removed in version 1.5.") - public constructor( - profileOverride: String? = null, - client: Lazy = lazy { ImdsClient() }, - platformProvider: PlatformEnvironProvider = PlatformProvider.System, - @Suppress("UNUSED_PARAMETER") clock: Clock = Clock.System, - ) : this( - profileOverride, - client.value, - platformProvider = platformProvider as? PlatformProvider ?: PlatformProvider.System, - ) - - private val actualPlatformProvider = platformProvider - - @Deprecated("This property is retained for backwards compatibility but no longer needs to be public and will be removed in version 1.5.") - public val platformProvider: PlatformEnvironProvider = actualPlatformProvider - - private val manageClient: Boolean = client == null - - private val actualClient = client ?: ImdsClient { - this.platformProvider = actualPlatformProvider - } - - @Deprecated("This property is retained for backwards compatibility but no longer needs to be public and will be removed in version 1.5.") - public val client: Lazy - get() = lazyOf(actualClient) - - private val instanceProfileName = asyncLazy { - instanceProfileName ?: resolveEc2InstanceProfileName(platformProvider) - } - - @Deprecated("This property is retained for backwards compatibility but no longer needs to be public and will be removed in version 1.5.") - public val profileOverride: String? = instanceProfileName - - private val providerDisabled = asyncLazy { - resolveDisableEc2Metadata(platformProvider) ?: false - } - - /** - * Tracks the known-good version of IMDS APIs available in the local environment. This starts as `null` and will be - * updated after the first successful API call. - */ - private var apiVersion: ApiVersion? = null - - private val urlBase: String - get() = (apiVersion ?: ApiVersion.EXTENDED).urlBase - private var previousCredentials: Credentials? = null - /** - * Tracks the instance profile name resolved from IMDS. This starts as `null` and will be updated after a - * successful API call. Note that if [instanceProfileName] is set, profile name resolution will be skipped. - */ - private var resolvedProfileName: String? = null - - /** - * A deduplicator for resolving credentials and tracking mutable state about IMDS - */ - private val sfg = SingleFlightGroup() + // the time to refresh the Credentials. If set, it will take precedence over the Credentials' expiration time + private var nextRefresh: Instant? = null - override suspend fun resolve(attributes: Attributes): Credentials = sfg.singleFlight(::resolveSingleFlight) + // protects previousCredentials and nextRefresh + private val mu = Mutex() - private suspend fun resolveSingleFlight(): Credentials { - if (providerDisabled.get()) { + override suspend fun resolve(attributes: Attributes): Credentials { + if (AwsSdkSetting.AwsEc2MetadataDisabled.resolve(platformProvider) == true) { throw CredentialsNotLoadedException("AWS EC2 metadata is explicitly disabled; credentials not loaded") } - val profileName = instanceProfileName.get() ?: resolvedProfileName ?: try { - actualClient.get(urlBase).also { - if (apiVersion == null) { - // Tried EXTENDED and it worked; remember that for the future - apiVersion = ApiVersion.EXTENDED + // if we have previously served IMDS credentials and it's not time for a refresh, just return the previous credentials + mu.withLock { + previousCredentials?.run { + nextRefresh?.takeIf { clock.now() < it }?.run { + return previousCredentials!! } } - } catch (ex: EC2MetadataError) { - when { - apiVersion == null && ex.status == HttpStatusCode.NotFound -> { - // Tried EXTENDED and that didn't work; fallback to LEGACY - apiVersion = ApiVersion.LEGACY - return resolveSingleFlight() - } - - ex.status == HttpStatusCode.NotFound -> { - coroutineContext.info { - "Received 404 when loading profile name. This instance may not have an associated profile." - } - throw ex - } - - else -> return usePreviousCredentials() - ?: throw ImdsProfileException(ex).wrapAsCredentialsProviderException() - } - } catch (ex: IOException) { - return usePreviousCredentials() ?: throw ImdsProfileException(ex).wrapAsCredentialsProviderException() - } catch (ex: Exception) { - throw ImdsProfileException(ex).wrapAsCredentialsProviderException() } - val credsPayload = try { - actualClient.get("$urlBase$profileName") - } catch (ex: EC2MetadataError) { - when { - apiVersion == null && ex.status == HttpStatusCode.NotFound -> { - // Tried EXTENDED and that didn't work; fallback to LEGACY - apiVersion = ApiVersion.LEGACY - return resolveSingleFlight() - } - - instanceProfileName.get() == null && ex.status == HttpStatusCode.NotFound -> { - // A previously-resolved profile is now invalid; forget the resolved name and re-resolve - resolvedProfileName = null - return resolveSingleFlight() - } - - else -> return usePreviousCredentials() - ?: throw ImdsCredentialsException(profileName, ex).wrapAsCredentialsProviderException() - } - } catch (ex: IOException) { - return usePreviousCredentials() - ?: throw ImdsCredentialsException(profileName, ex).wrapAsCredentialsProviderException() + val profileName = try { + profileOverride ?: loadProfile() } catch (ex: Exception) { - throw ImdsCredentialsException(profileName, ex).wrapAsCredentialsProviderException() + return useCachedCredentials(ex) ?: throw CredentialsProviderException("failed to load instance profile", ex) } - if (instanceProfileName.get() == null) { - // No profile name was provided at construction time; cache the resolved name - resolvedProfileName = profileName + val payload = try { + client.value.get("$CREDENTIALS_BASE_PATH$profileName") + } catch (ex: Exception) { + return useCachedCredentials(ex) ?: throw CredentialsProviderException("failed to load credentials", ex) } - val deserializer = JsonDeserializer(credsPayload.encodeToByteArray()) + val deserializer = JsonDeserializer(payload.encodeToByteArray()) return when (val resp = deserializeJsonCredentials(deserializer)) { is JsonCredentialsResponse.SessionCredentials -> { - val creds = credentials( + nextRefresh = if (resp.expiration != null && resp.expiration < clock.now()) { + coroutineContext.warn { + "Attempting credential expiration extension due to a credential service availability issue. " + + "A refresh of these credentials will be attempted again in " + + "${ DEFAULT_CREDENTIALS_REFRESH_SECONDS / 60 } minutes." + } + clock.now() + DEFAULT_CREDENTIALS_REFRESH_SECONDS.seconds + } else { + null + } + + val creds = Credentials( resp.accessKeyId, resp.secretAccessKey, resp.sessionToken, resp.expiration, PROVIDER_NAME, - resp.accountId, ).withBusinessMetric(AwsBusinessMetric.Credentials.CREDENTIALS_IMDS) - creds.also { previousCredentials = it } + creds.also { + mu.withLock { previousCredentials = it } + } } - is JsonCredentialsResponse.Error -> when (resp.code) { - CODE_ASSUME_ROLE_UNAUTHORIZED_ACCESS -> throw ProviderConfigurationException("Incorrect IMDS/IAM configuration: [${resp.code}] ${resp.message}. Hint: Does this role have a trust relationship with EC2?") - else -> throw CredentialsProviderException("Error retrieving credentials from IMDS: code=${resp.code}; ${resp.message}") + is JsonCredentialsResponse.Error -> { + when (resp.code) { + CODE_ASSUME_ROLE_UNAUTHORIZED_ACCESS -> throw ProviderConfigurationException("Incorrect IMDS/IAM configuration: [${resp.code}] ${resp.message}. Hint: Does this role have a trust relationship with EC2?") + else -> throw CredentialsProviderException("Error retrieving credentials from IMDS: code=${resp.code}; ${resp.message}") + } } } } override fun close() { - if (manageClient) { - actualClient.close() + if (client.isInitialized()) { + client.value.close() } } - private suspend fun usePreviousCredentials(): Credentials? = - previousCredentials?.apply { + private suspend fun loadProfile() = try { + client.value.get(CREDENTIALS_BASE_PATH) + } catch (ex: EC2MetadataError) { + if (ex.statusCode == HttpStatusCode.NotFound.value) { coroutineContext.info { - "Attempting to reuse previously-fetched credentials (expiration = $expiration)" + "Received 404 from IMDS when loading profile information. Hint: This instance may not have an " + + "IAM role associated." } } - - override fun toString(): String = this.simpleClassName - - /** - * Identifies different versions of IMDS APIs for fetching credentials - */ - private enum class ApiVersion(val urlBase: String) { - /** - * The original, now-deprecated API - */ - LEGACY("/latest/meta-data/iam/security-credentials/"), - - /** - * The new API which provides `AccountId` and potentially other fields in the future - */ - EXTENDED("/latest/meta-data/iam/security-credentials-extended/"), + throw ex } -} - -internal class ImdsCredentialsException( - profileName: String, - cause: Throwable, -) : RuntimeException("Failed to load credentials for EC2 instance profile \"$profileName\"", cause) -internal class ImdsProfileException(cause: Throwable) : RuntimeException("Failed to load instance profile name", cause) + private suspend fun useCachedCredentials(ex: Exception): Credentials? = when { + ex is IOException || ex is EC2MetadataError && ex.statusCode == HttpStatusCode.InternalServerError.value -> { + mu.withLock { + previousCredentials?.apply { nextRefresh = clock.now() + DEFAULT_CREDENTIALS_REFRESH_SECONDS.seconds } + } + } + else -> null + } -private fun Throwable.wrapAsCredentialsProviderException() = - CredentialsProviderException(message.orEmpty(), this) + override fun toString(): String = this.simpleClassName +} diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/ProfileCredentialsProvider.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/ProfileCredentialsProvider.kt index 0b21677ee5e..81935c0be9e 100644 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/ProfileCredentialsProvider.kt +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/ProfileCredentialsProvider.kt @@ -106,10 +106,12 @@ public class ProfileCredentialsProvider @InternalSdkApi constructor( private val namedProviders = mapOf( "Environment" to EnvironmentCredentialsProvider(platformProvider::getenv), "Ec2InstanceMetadata" to ImdsCredentialsProvider( - instanceProfileName = profileName, - client = ImdsClient { - platformProvider = this@ProfileCredentialsProvider.platformProvider - engine = httpClient + profileOverride = profileName, + client = lazy { + ImdsClient { + platformProvider = this@ProfileCredentialsProvider.platformProvider + engine = httpClient + } }, platformProvider = platformProvider, ), diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/internal/CredentialsExt.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/internal/CredentialsExt.kt index 48bf1fb443e..3f40a0e584d 100644 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/internal/CredentialsExt.kt +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/internal/CredentialsExt.kt @@ -29,6 +29,3 @@ internal fun credentials( } return Credentials(accessKeyId, secretAccessKey, sessionToken, expiration, attributes = attributes) } - -internal val Credentials.accountId: String? - get() = attributes.getOrNull(AwsClientOption.AccountId) diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/AwsSdkSetting.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/AwsSdkSetting.kt index dd8a0240058..c8018397e0f 100644 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/AwsSdkSetting.kt +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/AwsSdkSetting.kt @@ -105,14 +105,6 @@ public object AwsSdkSetting { public val AwsEc2MetadataServiceEndpointMode: EnvironmentSetting = strEnvSetting("aws.ec2MetadataServiceEndpointMode", "AWS_EC2_METADATA_SERVICE_ENDPOINT_MODE") - /** - * The name of the EC2 - * [instance profile](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2_instance-profiles.html) - * to use for resolving credentials on an EC2 instance. Specifying this name disables profile name discovery. - */ - public val AwsEc2InstanceProfileName: EnvironmentSetting = - strEnvSetting("aws.ec2InstanceProfileName", "AWS_EC2_INSTANCE_PROFILE_NAME") - // TODO Currently env/system properties around role ARN, role session name, etc are restricted to the STS web // identity provider. They should be applied more broadly but this needs fleshed out across AWS SDKs before we can // do so. diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsClient.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsClient.kt index 58b9126930e..3f42f62edc4 100644 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsClient.kt +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsClient.kt @@ -114,10 +114,10 @@ public class ImdsClient private constructor(builder: Builder) : InstanceMetadata override suspend fun deserialize(context: ExecutionContext, call: HttpCall): String { val response = call.response if (response.status.isSuccess()) { - val payload = response.body.readAll() ?: throw EC2MetadataError(response.status, "no metadata payload") + val payload = response.body.readAll() ?: throw EC2MetadataError(response.status.value, "no metadata payload") return payload.decodeToString() } else { - throw EC2MetadataError(response.status, "error retrieving instance metadata: ${response.status.description}") + throw EC2MetadataError(response.status.value, "error retrieving instance metadata: ${response.status.description}") } } } diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsResolvers.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsResolvers.kt deleted file mode 100644 index 52a30fbad09..00000000000 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsResolvers.kt +++ /dev/null @@ -1,29 +0,0 @@ -package aws.sdk.kotlin.runtime.config.imds - -import aws.sdk.kotlin.runtime.InternalSdkApi -import aws.sdk.kotlin.runtime.config.AwsSdkSetting -import aws.sdk.kotlin.runtime.config.profile.AwsProfile -import aws.sdk.kotlin.runtime.config.profile.ec2InstanceProfileName -import aws.sdk.kotlin.runtime.config.profile.ec2MetadataDisabled -import aws.sdk.kotlin.runtime.config.profile.loadAwsSharedConfig -import aws.smithy.kotlin.runtime.config.resolve -import aws.smithy.kotlin.runtime.util.LazyAsyncValue -import aws.smithy.kotlin.runtime.util.PlatformProvider -import aws.smithy.kotlin.runtime.util.asyncLazy - -/** - * Attempts to resolve a named EC2 instance profile to use which allows bypassing auto-discovery - */ -@InternalSdkApi -public suspend fun resolveEc2InstanceProfileName( - provider: PlatformProvider = PlatformProvider.System, - profile: LazyAsyncValue = asyncLazy { loadAwsSharedConfig(provider).activeProfile }, -): String? = AwsSdkSetting.AwsEc2InstanceProfileName.resolve(provider) ?: profile.get().ec2InstanceProfileName - -/** - * Attempts to resolve the flag which disables the use of IMDS for credentials - */ -public suspend fun resolveDisableEc2Metadata( - provider: PlatformProvider = PlatformProvider.System, - profile: LazyAsyncValue = asyncLazy { loadAwsSharedConfig(provider).activeProfile }, -): Boolean? = AwsSdkSetting.AwsEc2MetadataDisabled.resolve(provider) ?: profile.get().ec2MetadataDisabled diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/TokenMiddleware.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/TokenMiddleware.kt index 0851eeb1718..68a862b0661 100644 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/TokenMiddleware.kt +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/TokenMiddleware.kt @@ -6,6 +6,7 @@ package aws.sdk.kotlin.runtime.config.imds import aws.smithy.kotlin.runtime.http.* +import aws.smithy.kotlin.runtime.http.complete import aws.smithy.kotlin.runtime.http.operation.ModifyRequestMiddleware import aws.smithy.kotlin.runtime.http.operation.SdkHttpOperation import aws.smithy.kotlin.runtime.http.operation.SdkHttpRequest @@ -63,10 +64,10 @@ internal class TokenMiddleware( val call = httpClient.call(tokenReq) return try { - when (val status = call.response.status) { + when (call.response.status) { HttpStatusCode.OK -> { - val ttl = call.response.headers[X_AWS_EC2_METADATA_TOKEN_TTL_SECONDS]?.toLong() ?: throw EC2MetadataError(status, "No TTL provided in IMDS response") - val token = call.response.body.readAll() ?: throw EC2MetadataError(status, "No token provided in IMDS response") + val ttl = call.response.headers[X_AWS_EC2_METADATA_TOKEN_TTL_SECONDS]?.toLong() ?: throw EC2MetadataError(200, "No TTL provided in IMDS response") + val token = call.response.body.readAll() ?: throw EC2MetadataError(200, "No token provided in IMDS response") val expires = clock.now() + ttl.seconds Token(token, expires) } @@ -75,7 +76,7 @@ internal class TokenMiddleware( HttpStatusCode.Forbidden -> "Request forbidden: IMDS is disabled or the caller has insufficient permissions." else -> "Failed to retrieve IMDS token" } - throw EC2MetadataError(status, message) + throw EC2MetadataError(call.response.status.value, message) } } } finally { diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/profile/AwsProfile.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/profile/AwsProfile.kt index 0857f79b70b..3ec07b3a795 100644 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/profile/AwsProfile.kt +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/profile/AwsProfile.kt @@ -185,20 +185,6 @@ public val AwsProfile.responseChecksumValidation: ResponseHttpChecksumConfig? public val AwsProfile.authSchemePreference: String? get() = getOrNull("auth_scheme_preference") -/** - * Specifies a named EC2 instance profile to use which allows bypassing auto-discovery - */ -@InternalSdkApi -public val AwsProfile.ec2InstanceProfileName: String? - get() = getOrNull("ec2_instance_profile_name") - -/** - * The flag which disables the use of IMDS for credentials - */ -@InternalSdkApi -public val AwsProfile.ec2MetadataDisabled: Boolean? - get() = getBooleanOrNull("disable_ec2_metadata") - /** * Parse a config value as a boolean, ignoring case. */ diff --git a/aws-runtime/aws-config/common/test-resources/default-provider-chain/imds_default_chain_error/test-case.json b/aws-runtime/aws-config/common/test-resources/default-provider-chain/imds_default_chain_error/test-case.json index 035482c750a..c182d0bbbaf 100644 --- a/aws-runtime/aws-config/common/test-resources/default-provider-chain/imds_default_chain_error/test-case.json +++ b/aws-runtime/aws-config/common/test-resources/default-provider-chain/imds_default_chain_error/test-case.json @@ -2,6 +2,6 @@ "name": "imds-default-chain", "docs": "IMDS isn't specifically configured but is loaded as part of the default chain. This has the exact same HTTP traffic as imds_no_iam_role, they are equivalent.", "result": { - "ErrorContains": "Failed to load instance profile name" + "ErrorContains": "failed to load instance profile" } } diff --git a/aws-runtime/aws-config/common/test-resources/default-provider-chain/imds_no_iam_role/test-case.json b/aws-runtime/aws-config/common/test-resources/default-provider-chain/imds_no_iam_role/test-case.json index d89927ffaa7..0df36927015 100644 --- a/aws-runtime/aws-config/common/test-resources/default-provider-chain/imds_no_iam_role/test-case.json +++ b/aws-runtime/aws-config/common/test-resources/default-provider-chain/imds_no_iam_role/test-case.json @@ -2,6 +2,6 @@ "name": "imds-token-fail", "docs": "attempts to acquire an IMDS token, but the instance doesn't have a role configured", "result": { - "ErrorContains": "Failed to load instance profile name" + "ErrorContains": "failed to load instance profile" } } diff --git a/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProviderTest.kt b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProviderTest.kt index 99e9274c7da..90a30e9e9c8 100644 --- a/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProviderTest.kt +++ b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProviderTest.kt @@ -4,154 +4,580 @@ */ package aws.sdk.kotlin.runtime.auth.credentials -import aws.sdk.kotlin.runtime.auth.credentials.internal.accountId -import aws.sdk.kotlin.runtime.config.imds.EC2MetadataError -import aws.sdk.kotlin.runtime.util.VerifyingInstanceMetadataProvider +import aws.sdk.kotlin.runtime.config.AwsSdkSetting +import aws.sdk.kotlin.runtime.config.imds.* +import aws.sdk.kotlin.runtime.config.imds.DEFAULT_TOKEN_TTL_SECONDS +import aws.sdk.kotlin.runtime.http.interceptors.businessmetrics.AwsBusinessMetric +import aws.sdk.kotlin.runtime.http.interceptors.businessmetrics.withBusinessMetric +import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials import aws.smithy.kotlin.runtime.auth.awscredentials.CredentialsProviderException -import aws.smithy.kotlin.runtime.http.HttpStatusCode +import aws.smithy.kotlin.runtime.http.* +import aws.smithy.kotlin.runtime.http.engine.HttpClientEngineBase +import aws.smithy.kotlin.runtime.http.engine.HttpClientEngineConfig +import aws.smithy.kotlin.runtime.http.request.HttpRequest +import aws.smithy.kotlin.runtime.http.response.HttpResponse +import aws.smithy.kotlin.runtime.httptest.TestEngine +import aws.smithy.kotlin.runtime.httptest.buildTestConnection +import aws.smithy.kotlin.runtime.io.IOException +import aws.smithy.kotlin.runtime.net.Host +import aws.smithy.kotlin.runtime.net.Scheme +import aws.smithy.kotlin.runtime.net.url.Url +import aws.smithy.kotlin.runtime.operation.ExecutionContext +import aws.smithy.kotlin.runtime.time.Instant +import aws.smithy.kotlin.runtime.time.ManualClock +import aws.smithy.kotlin.runtime.time.epochMilliseconds +import aws.smithy.kotlin.runtime.time.fromEpochMilliseconds import aws.smithy.kotlin.runtime.util.TestPlatformProvider -import io.kotest.core.spec.style.FunSpec -import io.kotest.datatest.WithDataTestName -import io.kotest.datatest.withData -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.descriptors.SerialDescriptor -import kotlinx.serialization.encoding.Decoder -import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.* -import kotlin.test.assertEquals -import kotlin.test.assertIs -import kotlin.test.assertNotNull -import kotlin.test.fail - -class ImdsCredentialsProviderTest : - FunSpec({ - context("ImdsCredentialsProviderTest") { - withData(testCases) { testCase -> - println("**** TC: ${testCases.indexOf(testCase)}=${testCase.summary}") - val imds = imds(testCase.expectations) - - val provider = ImdsCredentialsProvider( - instanceProfileName = testCase.config.profileName, - client = imds, - platformProvider = TestPlatformProvider(env = testCase.config.envVars), - ) - - testCase.outcomes.forEachIndexed { index, outcome -> - println("**** Outcome: $index=$outcome") - assertCredentials(provider, index, outcome) - } +import io.kotest.matchers.string.shouldContain +import kotlinx.coroutines.test.runTest +import kotlin.test.* +import kotlin.time.Duration.Companion.seconds - imds.verifyComplete() - } +class ImdsCredentialsProviderTest { + + private val ec2MetadataDisabledPlatform = TestPlatformProvider( + env = mapOf(AwsSdkSetting.AwsEc2MetadataDisabled.envVar to "true"), + ) + private val ec2MetadataEnabledPlatform = TestPlatformProvider() + + @Test + fun testImdsDisabled() = runTest { + val platform = ec2MetadataDisabledPlatform + val provider = ImdsCredentialsProvider(platformProvider = platform) + assertFailsWith { + provider.resolve() + }.message.shouldContain("AWS EC2 metadata is explicitly disabled; credentials not loaded") + } + + @Test + fun testSuccess() = runTest { + val testClock = ManualClock(Instant.fromEpochMilliseconds(Instant.now().epochMilliseconds)) + val expiration0 = Instant.fromEpochMilliseconds(testClock.now().epochMilliseconds) + val expiration1 = expiration0 + 2.seconds + + val connection = buildTestConnection { + expect( + tokenRequest("http://169.254.169.254", DEFAULT_TOKEN_TTL_SECONDS), + tokenResponse(DEFAULT_TOKEN_TTL_SECONDS, "TOKEN_A"), + ) + expect( + imdsRequest("http://169.254.169.254/latest/meta-data/iam/security-credentials/", "TOKEN_A"), + imdsResponse("imds-test-role"), + ) + expect( + imdsRequest( + "http://169.254.169.254/latest/meta-data/iam/security-credentials/imds-test-role", + "TOKEN_A", + ), + imdsResponse( + """ + { + "Code" : "Success", + "LastUpdated" : "2021-09-17T20:57:08Z", + "Type" : "AWS-HMAC", + "AccessKeyId" : "ASIARTEST0", + "SecretAccessKey" : "xjtest0", + "Token" : "IQote///test0", + "Expiration" : "$expiration0" + } + """, + ), + ) + + // verify that profile is re-retrieved after credentials expiration + expect( + imdsRequest("http://169.254.169.254/latest/meta-data/iam/security-credentials/", "TOKEN_A"), + imdsResponse("imds-test-role-2"), + ) + expect( + imdsRequest( + "http://169.254.169.254/latest/meta-data/iam/security-credentials/imds-test-role-2", + "TOKEN_A", + ), + imdsResponse( + """ + { + "Code" : "Success", + "LastUpdated" : "2021-09-17T20:57:08Z", + "Type" : "AWS-HMAC", + "AccessKeyId" : "ASIARTEST1", + "SecretAccessKey" : "xjtest1", + "Token" : "IQote///test1", + "Expiration" : "$expiration1" + } + """, + ), + ) } - }) { - private companion object { - val json = Json { - @OptIn(ExperimentalSerializationApi::class) - decodeEnumsCaseInsensitive = true + + val client = ImdsClient { + engine = connection + clock = testClock } - val testCases = json.decodeFromString>(imdsCredentialsTestSpec) + val provider = ImdsCredentialsProvider( + client = lazyOf(client), + clock = testClock, + platformProvider = ec2MetadataEnabledPlatform, + ) + + val actual0 = provider.resolve() + val expected0 = Credentials( + "ASIARTEST0", + "xjtest0", + "IQote///test0", + expiration0, + "IMDSv2", + ).withBusinessMetric(AwsBusinessMetric.Credentials.CREDENTIALS_IMDS) + assertEquals(expected0, actual0) - fun imds(expectations: List) = - VerifyingInstanceMetadataProvider(expectations.map { it.get to it.response.asStringProvider() }) + testClock.advance(1.seconds) - fun Response.asStringProvider(): () -> String = { - if (status == 200) requireNotNull(body) else throw EC2MetadataError(HttpStatusCode.fromValue(status), "err") + val actual1 = provider.resolve() + val expected1 = Credentials( + "ASIARTEST1", + "xjtest1", + "IQote///test1", + expiration1, + "IMDSv2", + ).withBusinessMetric(AwsBusinessMetric.Credentials.CREDENTIALS_IMDS) + assertEquals(expected1, actual1) + + connection.assertRequests() + } + + @Test + fun testSuccessProfileOverride() = runTest { + val testClock = ManualClock() + val expiration = Instant.fromEpochMilliseconds(testClock.now().epochMilliseconds) + + val connection = buildTestConnection { + expect( + tokenRequest("http://169.254.169.254", DEFAULT_TOKEN_TTL_SECONDS), + tokenResponse(DEFAULT_TOKEN_TTL_SECONDS, "TOKEN_A"), + ) + // no request for profile, go directly to retrieving role credentials + expect( + imdsRequest( + "http://169.254.169.254/latest/meta-data/iam/security-credentials/imds-test-role", + "TOKEN_A", + ), + imdsResponse( + """ + { + "Code" : "Success", + "LastUpdated" : "2021-09-17T20:57:08Z", + "Type" : "AWS-HMAC", + "AccessKeyId" : "ASIARTEST", + "SecretAccessKey" : "xjtest", + "Token" : "IQote///test", + "Expiration" : "$expiration" + } + """, + ), + ) + } + + val client = ImdsClient { + engine = connection + clock = testClock } - suspend fun assertCredentials(provider: ImdsCredentialsProvider, index: Int, outcome: Outcome) { - val result = runCatching { provider.resolve() }.also { println("**** Got $it") } + val provider = ImdsCredentialsProvider( + profileOverride = "imds-test-role", + client = lazyOf(client), + clock = testClock, + platformProvider = ec2MetadataEnabledPlatform, + ) - (result.exceptionOrNull() as? AssertionError)?.let { throw it } // Rethrow any failed assertions + val actual = provider.resolve() + val expected = Credentials( + "ASIARTEST", + "xjtest", + "IQote///test", + expiration, + "IMDSv2", + ).withBusinessMetric(AwsBusinessMetric.Credentials.CREDENTIALS_IMDS) + assertEquals(expected, actual) - when (outcome.result) { - Result.CREDENTIALS -> { - val creds = result.getOrNull() ?: fail("Test index $index: expected credentials but got $result") - assertEquals(creds.accountId, outcome.accountId, "Test index $index: Unexpected account ID value") - } + connection.assertRequests() + } - Result.NO_CREDENTIALS -> { - val ex = result.exceptionOrNull() ?: fail("Test index $index: Expected exception but got $result") - assertIs(ex, "Test index $index: Unexpected exception type $ex") - } + @Test + fun testTokenFailure() = runTest { + // when attempting to retrieve initial token, IMDS replied with 403, indicating IMDS is disabled or not allowed through permissions + val connection = buildTestConnection { + expect( + tokenRequest("http://169.254.169.254", DEFAULT_TOKEN_TTL_SECONDS), + HttpResponse(HttpStatusCode.Forbidden, Headers.Empty, HttpBody.Empty), + ) + } + + val testClock = ManualClock() + val client = ImdsClient { + engine = connection + clock = testClock + } + + val provider = ImdsCredentialsProvider( + client = lazyOf(client), + clock = testClock, + platformProvider = ec2MetadataEnabledPlatform, + ) + + val ex = assertFailsWith { + provider.resolve() + } + ex.message.shouldContain("failed to load instance profile") + assertIs(ex.cause) + ex.cause!!.message.shouldContain("Request forbidden") + + connection.assertRequests() + } + + @Test + fun testNoInstanceProfileConfigured() = runTest { + val connection = buildTestConnection { + expect( + tokenRequest("http://169.254.169.254", DEFAULT_TOKEN_TTL_SECONDS), + tokenResponse(DEFAULT_TOKEN_TTL_SECONDS, "TOKEN_A"), + ) + expect( + imdsRequest("http://169.254.169.254/latest/meta-data/iam/security-credentials/", "TOKEN_A"), + HttpResponse( + HttpStatusCode.NotFound, + Headers.Empty, + HttpBody.fromBytes( + """ + + + + 404 - Not Found + + +

404 - Not Found

+ + + """.trimIndent().encodeToByteArray(), + ), + ), + ) + } + + val testClock = ManualClock() + val client = ImdsClient { + engine = connection + clock = testClock + } + + val provider = ImdsCredentialsProvider( + client = lazyOf(client), + clock = testClock, + platformProvider = ec2MetadataEnabledPlatform, + ) + + assertFailsWith { + provider.resolve() + }.message.shouldContain("failed to load instance profile") + + connection.assertRequests() + } + + // SDK can send a request if expired credentials are available. + // If the credentials provider can return expired credentials, that means the SDK can use them, + // because no other checks are done before using the credentials. + @Test + fun testCanReturnExpiredCredentials() = runTest { + val connection = buildTestConnection { + expect( + tokenRequest("http://169.254.169.254", DEFAULT_TOKEN_TTL_SECONDS), + tokenResponse(DEFAULT_TOKEN_TTL_SECONDS, "TOKEN_A"), + ) + expect( + imdsRequest( + "http://169.254.169.254/latest/meta-data/iam/security-credentials/imds-test-role", + "TOKEN_A", + ), + imdsResponse( + """ + { + "Code" : "Success", + "LastUpdated" : "2021-09-17T20:57:08Z", + "Type" : "AWS-HMAC", + "AccessKeyId" : "ASIARTEST", + "SecretAccessKey" : "xjtest", + "Token" : "IQote///test", + "Expiration" : "2021-09-18T03:31:56Z" + } + """, + ), + ) + } + + val testClock = ManualClock() + val client = ImdsClient { + engine = connection + clock = testClock + } + + val provider = ImdsCredentialsProvider( + profileOverride = "imds-test-role", + client = lazyOf(client), + clock = testClock, + platformProvider = ec2MetadataEnabledPlatform, + ) - Result.INVALID_PROFILE -> { - val ex = result.exceptionOrNull() ?: fail("Test index $index: Expected exception but got $result") - assertIs(ex, "Test index $index: Unexpected exception $ex") - val cause = assertNotNull(ex.cause, "Test index $index: Expected non-null exception cause") - assertIs(cause, "Test index $index: Unexpected cause $cause") + val actual = provider.resolve() + + val expected = Credentials( + accessKeyId = "ASIARTEST", + secretAccessKey = "xjtest", + sessionToken = "IQote///test", + expiration = Instant.fromEpochSeconds(1631935916), + providerName = "IMDSv2", + ).withBusinessMetric(AwsBusinessMetric.Credentials.CREDENTIALS_IMDS) + + assertEquals(expected, actual) + + connection.assertRequests() + } + + @Test + fun testUsesPreviousCredentialsOnReadTimeout() = runTest { + val testClock = ManualClock() + + // this engine throws read timeout exceptions for any requests after the initial one + // (i.e allow 1 TTL token and 1 credentials request) + val readTimeoutEngine = object : HttpClientEngineBase("readTimeout") { + var successfulCallCount = 0 + + override val config: HttpClientEngineConfig = HttpClientEngineConfig.Default + + override suspend fun roundTrip(context: ExecutionContext, request: HttpRequest): HttpCall { + if (successfulCallCount >= 2) { + throw IOException() + } else { + successfulCallCount += 1 + + return when (successfulCallCount) { + 1 -> HttpCall( + tokenRequest("http://169.254.169.254", DEFAULT_TOKEN_TTL_SECONDS), + tokenResponse(DEFAULT_TOKEN_TTL_SECONDS, "TOKEN_A"), + testClock.now(), + testClock.now(), + ) + + else -> HttpCall( + imdsRequest( + "http://169.254.169.254/latest/meta-data/iam/security-credentials/imds-test-role", + "TOKEN_A", + ), + imdsResponse( + """ + { + "Code" : "Success", + "LastUpdated" : "2021-09-17T20:57:08Z", + "Type" : "AWS-HMAC", + "AccessKeyId" : "ASIARTEST", + "SecretAccessKey" : "xjtest", + "Token" : "IQote///test", + "Expiration" : "2021-09-18T03:31:56Z" + }""", + ), + testClock.now(), + testClock.now(), + ) + } } } } - } -} -@Serializable -data class TestCase( - val summary: String, - val config: Config, - val expectations: List, - val outcomes: List, -) : WithDataTestName { - override fun dataTestName() = summary -} + val client = ImdsClient { + engine = readTimeoutEngine + clock = testClock + } + + val previousCredentials = Credentials( + accessKeyId = "ASIARTEST", + secretAccessKey = "xjtest", + sessionToken = "IQote///test", + expiration = Instant.fromEpochSeconds(1631935916), + providerName = "IMDSv2", + ).withBusinessMetric(AwsBusinessMetric.Credentials.CREDENTIALS_IMDS) -@Serializable -data class Config( - @SerialName("ec2InstanceProfileName") - val profileName: String? = null, + val provider = ImdsCredentialsProvider( + profileOverride = "imds-test-role", + client = lazyOf(client), + clock = testClock, + platformProvider = ec2MetadataEnabledPlatform, + ) - val envVars: Map = mapOf(), -) + // call the engine the first time to get a proper credentials response from IMDS + val credentials = provider.resolve() + assertEquals(credentials, previousCredentials) -@Serializable -data class Expectation( - val get: String, - val response: Response, -) + // call it again and get a read timeout exception from the engine + val newCredentials = provider.resolve() -@Serializable -data class Response( - val status: Int, + // should cause provider to return the previously-served credentials + assertEquals(newCredentials, previousCredentials) + } - @Serializable(with = StringOrObjectSerializer::class) - val body: String? = null, -) + @Test + fun testThrowsExceptionOnReadTimeoutWhenMissingPreviousCredentials() = runTest { + val readTimeoutEngine = TestEngine { _, _ -> throw IOException() } + val testClock = ManualClock() -@Serializable -data class Outcome( - val result: Result, - val accountId: String? = null, -) + val client = ImdsClient { + engine = readTimeoutEngine + clock = testClock + } -enum class Result { - CREDENTIALS, + val provider = ImdsCredentialsProvider( + profileOverride = "imds-test-role", + client = lazyOf(client), + clock = testClock, + platformProvider = ec2MetadataEnabledPlatform, + ) - @SerialName("no credentials") - NO_CREDENTIALS, + // a read timeout should cause an exception to be thrown, because we have no previous credentials to re-serve + assertFailsWith { + provider.resolve() + } + } - @SerialName("invalid profile") - INVALID_PROFILE, -} + @Test + fun testUsesPreviousCredentialsOnServerError() = runTest { + val testClock = ManualClock() -object StringOrObjectSerializer : KSerializer { - override val descriptor: SerialDescriptor = - SerialDescriptor("string-or-object", JsonElement.serializer().descriptor) + // this engine returns 500 errors for any requests after the initial one (i.e allow 1 TTL token and 1 credentials request) + val internalServerErrorEngine = object : HttpClientEngineBase("internalServerError") { + var successfulCallCount = 0 - override fun deserialize(decoder: Decoder): String { - val jsonDecoder = decoder as? JsonDecoder ?: error("This serializer only supports JSON") - return when (val element = jsonDecoder.decodeJsonElement()) { - is JsonPrimitive -> element.content - is JsonObject -> element.toString() - else -> error("Unsupported JSON type ${element::class}") + override val config: HttpClientEngineConfig = HttpClientEngineConfig.Default + + override suspend fun roundTrip(context: ExecutionContext, request: HttpRequest): HttpCall { + if (successfulCallCount >= 2) { + return HttpCall( + HttpRequest( + HttpMethod.GET, + Url { + scheme = Scheme.HTTP + host = Host.parse("test") + path.encoded = "/path/foo/bar" + }, + Headers.Empty, + HttpBody.Empty, + ), + HttpResponse(HttpStatusCode.InternalServerError, Headers.Empty, HttpBody.Empty), + testClock.now(), + testClock.now(), + ) + } else { + successfulCallCount += 1 + + return when (successfulCallCount) { + 1 -> HttpCall( + tokenRequest("http://169.254.169.254", DEFAULT_TOKEN_TTL_SECONDS), + tokenResponse(DEFAULT_TOKEN_TTL_SECONDS, "TOKEN_A"), + testClock.now(), + testClock.now(), + ) + + else -> HttpCall( + imdsRequest( + "http://169.254.169.254/latest/meta-data/iam/security-credentials/imds-test-role", + "TOKEN_A", + ), + imdsResponse( + """ + { + "Code" : "Success", + "LastUpdated" : "2021-09-17T20:57:08Z", + "Type" : "AWS-HMAC", + "AccessKeyId" : "ASIARTEST", + "SecretAccessKey" : "xjtest", + "Token" : "IQote///test", + "Expiration" : "2021-09-18T03:31:56Z" + }""", + ), + testClock.now(), + testClock.now(), + ) + } + } + } } + + val client = ImdsClient { + engine = internalServerErrorEngine + clock = testClock + } + + val previousCredentials = Credentials( + accessKeyId = "ASIARTEST", + secretAccessKey = "xjtest", + sessionToken = "IQote///test", + expiration = Instant.fromEpochSeconds(1631935916), + providerName = "IMDSv2", + ).withBusinessMetric(AwsBusinessMetric.Credentials.CREDENTIALS_IMDS) + + val provider = ImdsCredentialsProvider( + profileOverride = "imds-test-role", + client = lazyOf(client), + clock = testClock, + platformProvider = ec2MetadataEnabledPlatform, + ) + + // call the engine the first time to get a proper credentials response from IMDS + val credentials = provider.resolve() + assertEquals(previousCredentials, credentials) + + // call it again and get a 500 error from the engine + val newCredentials = provider.resolve() + + // should cause provider to return the previously-served credentials + assertEquals(newCredentials, previousCredentials) } - override fun serialize(encoder: Encoder, value: String) { - encoder.encodeString(value) + @Test + fun testThrowsExceptionOnServerErrorWhenMissingPreviousCredentials() = runTest { + val testClock = ManualClock() + + // this engine just returns 500 errors + val internalServerErrorEngine = TestEngine { _, _ -> + HttpCall( + HttpRequest( + HttpMethod.GET, + Url { + scheme = Scheme.HTTP + host = Host.parse("test") + path.encoded = "/path/foo/bar" + }, + Headers.Empty, + HttpBody.Empty, + ), + HttpResponse(HttpStatusCode.InternalServerError, Headers.Empty, HttpBody.Empty), + testClock.now(), + testClock.now(), + ) + } + + val client = ImdsClient { + engine = internalServerErrorEngine + clock = testClock + } + + val provider = ImdsCredentialsProvider( + profileOverride = "imds-test-role", + client = lazyOf(client), + clock = testClock, + platformProvider = ec2MetadataEnabledPlatform, + ) + + assertFailsWith { + provider.resolve() + } } } diff --git a/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProviderTestResources.kt b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProviderTestResources.kt deleted file mode 100644 index e5ddb3b191d..00000000000 --- a/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProviderTestResources.kt +++ /dev/null @@ -1,658 +0,0 @@ -package aws.sdk.kotlin.runtime.auth.credentials - -// language=JSON -val imdsCredentialsTestSpec = """ - [ - { - "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" - } - ] - } - ] -""".trimIndent() diff --git a/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/config/imds/ImdsClientTest.kt b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/config/imds/ImdsClientTest.kt index 71b34dd4425..4d492a000e5 100644 --- a/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/config/imds/ImdsClientTest.kt +++ b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/config/imds/ImdsClientTest.kt @@ -16,10 +16,7 @@ import aws.smithy.kotlin.runtime.util.TestPlatformProvider import io.kotest.matchers.string.shouldContain import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.* -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFailsWith -import kotlin.test.fail +import kotlin.test.* import kotlin.time.Duration.Companion.seconds class ImdsClientTest { @@ -203,7 +200,7 @@ class ImdsClientTest { client.get("/latest/metadata") } - assertEquals(HttpStatusCode.Forbidden, ex.status) + assertEquals(HttpStatusCode.Forbidden.value, ex.statusCode) connection.assertRequests() } diff --git a/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/util/VerifyingInstanceMetadataProvider.kt b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/util/VerifyingInstanceMetadataProvider.kt deleted file mode 100644 index 0adf75e08a3..00000000000 --- a/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/util/VerifyingInstanceMetadataProvider.kt +++ /dev/null @@ -1,32 +0,0 @@ -package aws.sdk.kotlin.runtime.util - -import aws.sdk.kotlin.runtime.config.imds.InstanceMetadataProvider -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.asserter - -class VerifyingInstanceMetadataProvider(expectations: List String>>) : InstanceMetadataProvider { - private val expectations = expectations.toMutableList() - - override fun close() = Unit - - override suspend fun get(path: String): String { - val trimmedPath = path.trimEnd('/') // remove trailing slashes to simplify testing - val next = assertNotNull(expectations.removeFirstOrNull(), "Call to \"$trimmedPath\" was unexpected!") - val (expectedPath, result) = next - assertEquals(trimmedPath, expectedPath, "Expected call to \"$expectedPath\" but got \"$trimmedPath\" instead!") - return result() - } - - fun verifyComplete() { - asserter.assertTrue( - lazyMessage = { - buildString { - appendLine("Not all expectations were met! Remaining paths which were not called:") - expectations.map { it.first }.forEach { appendLine("- $it") } - } - }, - actual = expectations.isEmpty(), - ) - } -} diff --git a/aws-runtime/aws-config/jvm/test/aws/sdk/kotlin/runtime/auth/credentials/DefaultChainCredentialsProviderTest.kt b/aws-runtime/aws-config/jvm/test/aws/sdk/kotlin/runtime/auth/credentials/DefaultChainCredentialsProviderTest.kt index b8489fc8f04..d56b63cd225 100644 --- a/aws-runtime/aws-config/jvm/test/aws/sdk/kotlin/runtime/auth/credentials/DefaultChainCredentialsProviderTest.kt +++ b/aws-runtime/aws-config/jvm/test/aws/sdk/kotlin/runtime/auth/credentials/DefaultChainCredentialsProviderTest.kt @@ -198,7 +198,7 @@ class DefaultChainCredentialsProviderTest { // In particular a test case only looks to verify a specific behavior and even though it // may fail at the correct spot, later providers may still be tried and also fail. val needle = expected.message - val haystack = ex.causesAndSuppressions().mapNotNull { it.message } + val haystack = listOf(ex.message!!) + ex.suppressed.map { it.message!! } + ex.suppressed.mapNotNull { it.cause?.message } val expectedErrorFound = haystack.any { it.contains(needle) } assertTrue(expectedErrorFound, "`$needle` not found in any of the chain exception messages: $haystack") } @@ -280,9 +280,3 @@ class DefaultChainCredentialsProviderTest { @Test fun testStsRetryOnError() = executeTest("retry_on_error") } - -private fun Throwable.causesAndSuppressions(): List = buildList { - add(this@causesAndSuppressions) - addAll(cause?.causesAndSuppressions().orEmpty()) - addAll(suppressedExceptions.flatMap { it.causesAndSuppressions() }) -} diff --git a/aws-runtime/aws-config/jvm/test/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProviderTest.kt b/aws-runtime/aws-config/jvm/test/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProviderTest.kt new file mode 100644 index 00000000000..6065431eeb6 --- /dev/null +++ b/aws-runtime/aws-config/jvm/test/aws/sdk/kotlin/runtime/auth/credentials/ImdsCredentialsProviderTest.kt @@ -0,0 +1,153 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package aws.sdk.kotlin.runtime.auth.credentials + +import aws.sdk.kotlin.runtime.config.imds.* +import aws.sdk.kotlin.runtime.config.imds.DEFAULT_TOKEN_TTL_SECONDS +import aws.smithy.kotlin.runtime.httptest.buildTestConnection +import aws.smithy.kotlin.runtime.time.ManualClock +import aws.smithy.kotlin.runtime.util.TestPlatformProvider +import io.mockk.coVerify +import io.mockk.spyk +import kotlinx.coroutines.test.runTest +import kotlin.test.* +import kotlin.time.Duration.Companion.minutes + +class ImdsCredentialsProviderTestJvm { + private val ec2MetadataEnabledPlatform = TestPlatformProvider() + + // FIXME Refactor mocking for KMP + // SDK can perform 3 successive requests with expired credentials. IMDS must only be called once. + @Test + fun testSuccessiveRequestsOnlyCallIMDSOnce() = runTest { + val connection = buildTestConnection { + expect( + tokenRequest("http://169.254.169.254", DEFAULT_TOKEN_TTL_SECONDS), + tokenResponse(DEFAULT_TOKEN_TTL_SECONDS, "TOKEN_A"), + ) + expect( + imdsRequest( + "http://169.254.169.254/latest/meta-data/iam/security-credentials/imds-test-role", + "TOKEN_A", + ), + imdsResponse( + """ + { + "Code" : "Success", + "LastUpdated" : "2021-09-17T20:57:08Z", + "Type" : "AWS-HMAC", + "AccessKeyId" : "ASIARTEST", + "SecretAccessKey" : "xjtest", + "Token" : "IQote///test", + "Expiration" : "2021-09-18T03:31:56Z" + } + """, + ), + ) + } + + val testClock = ManualClock() + + val client = spyk( + ImdsClient { + engine = connection + clock = testClock + }, + ) + + val provider = ImdsCredentialsProvider( + profileOverride = "imds-test-role", + client = lazyOf(client), + clock = testClock, + platformProvider = ec2MetadataEnabledPlatform, + ) + + // call resolve 3 times + repeat(3) { + provider.resolve() + } + + // make sure ImdsClient only gets called once + coVerify(exactly = 1) { + client.get(any()) + } + } + + // FIXME Refactor mocking for KMP + @Test + fun testDontRefreshUntilNextRefreshTimeHasPassed() = runTest { + val connection = buildTestConnection { + expect( + tokenRequest("http://169.254.169.254", DEFAULT_TOKEN_TTL_SECONDS), + tokenResponse(DEFAULT_TOKEN_TTL_SECONDS, "TOKEN_A"), + ) + expect( + imdsRequest( + "http://169.254.169.254/latest/meta-data/iam/security-credentials/imds-test-role", + "TOKEN_A", + ), + imdsResponse( + """ + { + "Code" : "Success", + "LastUpdated" : "2021-09-17T20:57:08Z", + "Type" : "AWS-HMAC", + "AccessKeyId" : "ASIARTEST", + "SecretAccessKey" : "xjtest", + "Token" : "IQote///test", + "Expiration" : "2021-09-18T03:31:56Z" + } + """, + ), + ) + expect( + imdsRequest( + "http://169.254.169.254/latest/meta-data/iam/security-credentials/imds-test-role", + "TOKEN_A", + ), + imdsResponse( + """ + { + "Code" : "Success", + "LastUpdated" : "2021-09-17T20:57:08Z", + "Type" : "AWS-HMAC", + "AccessKeyId" : "NEWCREDENTIALS", + "SecretAccessKey" : "shhh", + "Token" : "IQote///test", + "Expiration" : "2022-10-05T03:31:56Z" + } + """, + ), + ) + } + + val testClock = ManualClock() + + val client = spyk( + ImdsClient { + engine = connection + clock = testClock + }, + ) + + val provider = ImdsCredentialsProvider( + profileOverride = "imds-test-role", + client = lazyOf(client), + clock = testClock, + platformProvider = ec2MetadataEnabledPlatform, + ) + + val first = provider.resolve() + testClock.advance(20.minutes) // 20 minutes later, we should try to refresh the expired credentials + val second = provider.resolve() + + coVerify(exactly = 2) { + client.get(any()) + } + + // make sure we did not just serve the previous credentials + assertNotEquals(first, second) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a8e7af0d206..7f1031fe5ac 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -118,7 +118,6 @@ jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup-version" } kotest-assertions-core = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest-version" } kotest-assertions-core-jvm = { module = "io.kotest:kotest-assertions-core-jvm", version.ref = "kotest-version" } -kotest-framework-datatest = { module = "io.kotest:kotest-framework-datatest", version.ref = "kotest-version" } kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest-version" } kotlinx-benchmark-runtime = { module = "org.jetbrains.kotlinx:kotlinx-benchmark-runtime", version.ref = "kotlinx-benchmark-version" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization-version" }