
commonTest.aws.sdk.kotlin.runtime.auth.credentials.ImdsCredentialsProviderTest.kt Maven / Gradle / Ivy
/*
* 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.AwsSdkSetting
import aws.sdk.kotlin.runtime.config.imds.*
import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials
import aws.smithy.kotlin.runtime.auth.awscredentials.CredentialsProviderException
import aws.smithy.kotlin.runtime.http.Headers
import aws.smithy.kotlin.runtime.http.HttpBody
import aws.smithy.kotlin.runtime.http.HttpMethod
import aws.smithy.kotlin.runtime.http.HttpStatusCode
import aws.smithy.kotlin.runtime.http.content.ByteArrayContent
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.HttpCall
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.net.Host
import aws.smithy.kotlin.runtime.net.Scheme
import aws.smithy.kotlin.runtime.net.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.matchers.string.shouldContain
import io.mockk.coVerify
import io.mockk.spyk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlin.test.assertNotEquals
import kotlin.time.Duration.Companion.minutes
import kotlin.time.Duration.Companion.seconds
private val ec2MetadataDisabledPlatform = TestPlatformProvider(
env = mapOf(AwsSdkSetting.AwsEc2MetadataDisabled.envVar to "true"),
)
private val ec2MetadataEnabledPlatform = TestPlatformProvider()
@OptIn(ExperimentalCoroutinesApi::class)
class ImdsCredentialsProviderTest {
@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"
}
""",
),
)
}
val client = ImdsClient {
engine = connection
clock = testClock
}
val provider = ImdsCredentialsProvider(
client = lazyOf(client),
clock = testClock,
platformProvider = ec2MetadataEnabledPlatform,
)
val actual0 = provider.resolve()
val expected0 = Credentials(
"ASIARTEST0",
"xjtest0",
"IQote///test0",
expiration0,
"IMDSv2",
)
assertEquals(expected0, actual0)
testClock.advance(1.seconds)
val actual1 = provider.resolve()
val expected1 = Credentials(
"ASIARTEST1",
"xjtest1",
"IQote///test1",
expiration1,
"IMDSv2",
)
assertEquals(expected1, actual1)
}
@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
}
val provider = ImdsCredentialsProvider(
profileOverride = "imds-test-role",
client = lazyOf(client),
clock = testClock,
platformProvider = ec2MetadataEnabledPlatform,
)
val actual = provider.resolve()
val expected = Credentials(
"ASIARTEST",
"xjtest",
"IQote///test",
expiration,
"IMDSv2",
)
assertEquals(expected, actual)
}
@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")
}
@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,
ByteArrayContent(
"""
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")
}
// 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,
)
val actual = provider.resolve()
val expected = Credentials(
accessKeyId = "ASIARTEST",
secretAccessKey = "xjtest",
sessionToken = "IQote///test",
expiration = Instant.fromEpochSeconds(1631935916),
providerName = "IMDSv2",
)
assertEquals(expected, actual)
}
// 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())
}
}
@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)
}
@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 SdkIOException()
} 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 = readTimeoutEngine
clock = testClock
}
val previousCredentials = Credentials(
accessKeyId = "ASIARTEST",
secretAccessKey = "xjtest",
sessionToken = "IQote///test",
expiration = Instant.fromEpochSeconds(1631935916),
providerName = "IMDSv2",
)
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(credentials, previousCredentials)
// call it again and get a read timeout exception from the engine
val newCredentials = provider.resolve()
// should cause provider to return the previously-served credentials
assertEquals(newCredentials, previousCredentials)
}
@Test
fun testThrowsExceptionOnReadTimeoutWhenMissingPreviousCredentials() = runTest {
val readTimeoutEngine = TestEngine { _, _ -> throw SdkIOException() }
val testClock = ManualClock()
val client = ImdsClient {
engine = readTimeoutEngine
clock = testClock
}
val provider = ImdsCredentialsProvider(
profileOverride = "imds-test-role",
client = lazyOf(client),
clock = testClock,
platformProvider = ec2MetadataEnabledPlatform,
)
// a read timeout should cause an exception to be thrown, because we have no previous credentials to re-serve
assertFailsWith {
provider.resolve()
}
}
@Test
fun testUsesPreviousCredentialsOnServerError() = runTest {
val testClock = ManualClock()
// 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 val config: HttpClientEngineConfig = HttpClientEngineConfig.Default
override suspend fun roundTrip(context: ExecutionContext, request: HttpRequest): HttpCall {
if (successfulCallCount >= 2) {
return HttpCall(
HttpRequest(HttpMethod.GET, Url(Scheme.HTTP, Host.parse("test"), Scheme.HTTP.defaultPort, "/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",
)
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)
}
@Test
fun testThrowsExceptionOnServerErrorWhenMissingPreviousCredentials() = runTest {
val testClock = ManualClock()
// this engine just returns 500 errors
val internalServerErrorEngine = TestEngine { _, _ ->
HttpCall(
HttpRequest(HttpMethod.GET, Url(Scheme.HTTP, Host.parse("test"), Scheme.HTTP.defaultPort, "/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()
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy