Skip to content

Commit b442cd6

Browse files
authored
feat(rt): ec2 credentials provider (#348)
1 parent c05916b commit b442cd6

File tree

15 files changed

+721
-9
lines changed

15 files changed

+721
-9
lines changed

aws-runtime/aws-config/build.gradle.kts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ kotlin {
2323
implementation(project(":aws-runtime:http-client-engine-crt"))
2424
implementation(project(":aws-runtime:aws-http"))
2525

26+
// parsing common JSON credentials responses
27+
implementation("aws.smithy.kotlin:serde-json:$smithyKotlinVersion")
28+
2629

2730
// credential providers
2831
implementation("aws.sdk.kotlin.crt:aws-crt-kotlin:$crtKotlinVersion")

aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/EnvironmentCredentialsProvider.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55

66
package aws.sdk.kotlin.runtime.auth.credentials
77

8-
import aws.sdk.kotlin.runtime.ConfigurationException
98
import aws.sdk.kotlin.runtime.config.AwsSdkSetting
109
import aws.smithy.kotlin.runtime.util.Platform
1110

@@ -17,7 +16,7 @@ public constructor(private val getEnv: (String) -> String?) : CredentialsProvide
1716
public constructor() : this(Platform::getenv)
1817

1918
private fun requireEnv(variable: String): String =
20-
getEnv(variable) ?: throw ConfigurationException("Unable to get value from environment variable $variable")
19+
getEnv(variable) ?: throw ProviderConfigurationException("Missing value for environment variable `$variable`")
2120

2221
override suspend fun getCredentials(): Credentials = Credentials(
2322
accessKeyId = requireEnv(AwsSdkSetting.AwsAccessKeyId.environmentVariable),
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0.
4+
*/
5+
6+
package aws.sdk.kotlin.runtime.auth.credentials
7+
8+
import aws.sdk.kotlin.runtime.ClientException
9+
import aws.sdk.kotlin.runtime.ConfigurationException
10+
11+
/**
12+
* No credentials were available from this [CredentialsProvider]
13+
*/
14+
public class CredentialsNotLoadedException(message: String?, cause: Throwable? = null) :
15+
ClientException(message ?: "The provider could not provide credentials or required configuration was not set", cause)
16+
17+
/**
18+
* The [CredentialsProvider] was given an invalid configuration (e.g. invalid aws configuration file, invalid IMDS endpoint, etc)
19+
*/
20+
public class ProviderConfigurationException(message: String, cause: Throwable? = null) : ConfigurationException(message, cause)
21+
22+
/**
23+
* The [CredentialsProvider] experienced an error during credentials resolution
24+
*/
25+
public class CredentialsProviderException(message: String, cause: Throwable? = null) : ClientException(message, cause)
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0.
4+
*/
5+
6+
package aws.sdk.kotlin.runtime.auth.credentials
7+
8+
import aws.sdk.kotlin.runtime.config.AwsSdkSetting
9+
import aws.sdk.kotlin.runtime.config.imds.EC2MetadataError
10+
import aws.sdk.kotlin.runtime.config.imds.ImdsClient
11+
import aws.sdk.kotlin.runtime.config.resolve
12+
import aws.smithy.kotlin.runtime.http.HttpStatusCode
13+
import aws.smithy.kotlin.runtime.io.Closeable
14+
import aws.smithy.kotlin.runtime.logging.Logger
15+
import aws.smithy.kotlin.runtime.serde.json.JsonDeserializer
16+
import aws.smithy.kotlin.runtime.util.Platform
17+
import aws.smithy.kotlin.runtime.util.PlatformEnvironProvider
18+
import aws.smithy.kotlin.runtime.util.asyncLazy
19+
20+
private const val CREDENTIALS_BASE_PATH: String = "/latest/meta-data/iam/security-credentials"
21+
private const val CODE_ASSUME_ROLE_UNAUTHORIZED_ACCESS: String = "AssumeRoleUnauthorizedAccess"
22+
23+
/**
24+
* [CredentialsProvider] that uses EC2 instance metadata service (IMDS) to provide credentials information.
25+
* 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)
26+
* configured.
27+
*
28+
* See [EC2 IAM Roles](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/iam-roles-for-amazon-ec2.html) for more
29+
* information.
30+
*
31+
* @param profileOverride override the instance profile name. When retrieving credentials, a call must first be made to
32+
* `<IMDS_BASE_URL>/latest/meta-data/iam/security-credentials`. This returns the instance profile used. If
33+
* [profileOverride] is set, the initial call to retrieve the profile is skipped and the provided value is used instead.
34+
* @param client the IMDS client to use to resolve credentials information with
35+
* @param platformProvider the [PlatformEnvironProvider] instance
36+
*/
37+
public class ImdsCredentialsProvider(
38+
private val profileOverride: String? = null,
39+
private val client: Lazy<ImdsClient> = lazy { ImdsClient() },
40+
private val platformProvider: PlatformEnvironProvider = Platform
41+
) : CredentialsProvider, Closeable {
42+
private val logger = Logger.getLogger<ImdsCredentialsProvider>()
43+
44+
private val profile = asyncLazy {
45+
if (profileOverride != null) return@asyncLazy profileOverride
46+
loadProfile()
47+
}
48+
49+
override suspend fun getCredentials(): Credentials {
50+
if (AwsSdkSetting.AwsEc2MetadataDisabled.resolve(platformProvider) == true) {
51+
throw CredentialsNotLoadedException("AWS EC2 metadata is explicitly disabled; credentials not loaded")
52+
}
53+
54+
val profileName = try {
55+
profile.get()
56+
} catch (ex: Exception) {
57+
throw CredentialsProviderException("failed to load instance profile", ex)
58+
}
59+
60+
val payload = client.value.get("$CREDENTIALS_BASE_PATH/$profileName")
61+
val deserializer = JsonDeserializer(payload.encodeToByteArray())
62+
63+
return when (val resp = deserializeJsonCredentials(deserializer)) {
64+
is JsonCredentialsResponse.SessionCredentials -> Credentials(
65+
resp.accessKeyId,
66+
resp.secretAccessKey,
67+
resp.sessionToken,
68+
resp.expiration
69+
)
70+
is JsonCredentialsResponse.Error -> {
71+
when (resp.code) {
72+
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?")
73+
else -> throw CredentialsProviderException("Error retrieving credentials from IMDS: code=${resp.code}; ${resp.message}")
74+
}
75+
}
76+
}
77+
}
78+
79+
override fun close() {
80+
if (client.isInitialized()) {
81+
client.value.close()
82+
}
83+
}
84+
85+
private suspend fun loadProfile(): String {
86+
return try {
87+
client.value.get(CREDENTIALS_BASE_PATH)
88+
} catch (ex: EC2MetadataError) {
89+
if (ex.statusCode == HttpStatusCode.NotFound.value) {
90+
logger.info { "Received 404 from IMDS when loading profile information. Hint: This instance may not have an IAM role associated." }
91+
}
92+
throw ex
93+
}
94+
}
95+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/*
2+
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
* SPDX-License-Identifier: Apache-2.0.
4+
*/
5+
6+
package aws.sdk.kotlin.runtime.auth.credentials
7+
8+
import aws.sdk.kotlin.runtime.ClientException
9+
import aws.smithy.kotlin.runtime.serde.*
10+
import aws.smithy.kotlin.runtime.serde.json.JsonSerialName
11+
import aws.smithy.kotlin.runtime.time.Instant
12+
13+
/**
14+
* Exception thrown when credentials from response do not contain valid credentials or malformed JSON
15+
*/
16+
public class InvalidJsonCredentialsException(message: String, cause: Throwable? = null) : ClientException(message, cause)
17+
18+
/**
19+
* Common response elements for multiple HTTP credential providers (e.g. IMDS and ECS)
20+
*/
21+
internal sealed class JsonCredentialsResponse {
22+
/**
23+
* Credentials that can expire
24+
*/
25+
data class SessionCredentials(
26+
val accessKeyId: String,
27+
val secretAccessKey: String,
28+
val sessionToken: String,
29+
val expiration: Instant,
30+
) : JsonCredentialsResponse()
31+
32+
// TODO - add support for static credentials
33+
// {
34+
// "AccessKeyId" : "MUA...",
35+
// "SecretAccessKey" : "/7PC5om...."
36+
// }
37+
38+
// TODO - add support for assume role credentials
39+
// {
40+
// // fields to construct STS client:
41+
// "Region": "sts-region-name",
42+
// "AccessKeyId" : "MUA...",
43+
// "Expiration" : "2016-02-25T06:03:31Z", // optional
44+
// "SecretAccessKey" : "/7PC5om....",
45+
// "Token" : "AQoDY....=", // optional
46+
// // fields controlling the STS role:
47+
// "RoleArn": "...", // required
48+
// "RoleSessionName": "...", // required
49+
// // and also: DurationSeconds, ExternalId, SerialNumber, TokenCode, Policy
50+
// ...
51+
// }
52+
53+
/**
54+
* Response successfully parsed as an error response
55+
*/
56+
data class Error(val code: String, val message: String?) : JsonCredentialsResponse()
57+
}
58+
59+
/**
60+
* In general, the document looks something like:
61+
*
62+
* ```
63+
* {
64+
* "Code" : "Success",
65+
* "LastUpdated" : "2019-05-28T18:03:09Z",
66+
* "Type" : "AWS-HMAC",
67+
* "AccessKeyId" : "...",
68+
* "SecretAccessKey" : "...",
69+
* "Token" : "...",
70+
* "Expiration" : "2019-05-29T00:21:43Z"
71+
* }
72+
* ```
73+
*/
74+
internal suspend fun deserializeJsonCredentials(deserializer: Deserializer): JsonCredentialsResponse {
75+
val CODE_DESCRIPTOR = SdkFieldDescriptor(SerialKind.String, JsonSerialName("Code"))
76+
val ACCESS_KEY_ID_DESCRIPTOR = SdkFieldDescriptor(SerialKind.String, JsonSerialName("AccessKeyId"))
77+
val SECRET_ACCESS_KEY_ID_DESCRIPTOR = SdkFieldDescriptor(SerialKind.String, JsonSerialName("SecretAccessKey"))
78+
val SESSION_TOKEN_DESCRIPTOR = SdkFieldDescriptor(SerialKind.String, JsonSerialName("Token"))
79+
val EXPIRATION_DESCRIPTOR = SdkFieldDescriptor(SerialKind.Timestamp, JsonSerialName("Expiration"))
80+
val MESSAGE_DESCRIPTOR = SdkFieldDescriptor(SerialKind.String, JsonSerialName("Message"))
81+
82+
val OBJ_DESCRIPTOR = SdkObjectDescriptor.build {
83+
field(CODE_DESCRIPTOR)
84+
field(ACCESS_KEY_ID_DESCRIPTOR)
85+
field(SECRET_ACCESS_KEY_ID_DESCRIPTOR)
86+
field(SESSION_TOKEN_DESCRIPTOR)
87+
field(EXPIRATION_DESCRIPTOR)
88+
field(MESSAGE_DESCRIPTOR)
89+
}
90+
91+
var code: String? = null
92+
var accessKeyId: String? = null
93+
var secretAccessKey: String? = null
94+
var sessionToken: String? = null
95+
var expiration: Instant? = null
96+
var message: String? = null
97+
98+
try {
99+
deserializer.deserializeStruct(OBJ_DESCRIPTOR) {
100+
loop@while (true) {
101+
when (findNextFieldIndex()) {
102+
CODE_DESCRIPTOR.index -> code = deserializeString()
103+
ACCESS_KEY_ID_DESCRIPTOR.index -> accessKeyId = deserializeString()
104+
SECRET_ACCESS_KEY_ID_DESCRIPTOR.index -> secretAccessKey = deserializeString()
105+
SESSION_TOKEN_DESCRIPTOR.index -> sessionToken = deserializeString()
106+
EXPIRATION_DESCRIPTOR.index -> expiration = Instant.fromIso8601(deserializeString())
107+
108+
// error responses
109+
MESSAGE_DESCRIPTOR.index -> message = deserializeString()
110+
null -> break@loop
111+
else -> skipValue()
112+
}
113+
}
114+
}
115+
} catch (ex: DeserializationException) {
116+
throw InvalidJsonCredentialsException("invalid JSON credentials response", ex)
117+
}
118+
119+
return when (code?.lowercase()) {
120+
// IMDS does not appear to reply with `Code` missing but documentation indicates it may be possible
121+
"success", null -> {
122+
if (accessKeyId == null) throw InvalidJsonCredentialsException("missing field `AccessKeyId`")
123+
if (secretAccessKey == null) throw InvalidJsonCredentialsException("missing field `SecretAccessKey`")
124+
if (sessionToken == null) throw InvalidJsonCredentialsException("missing field `Token`")
125+
if (expiration == null) throw InvalidJsonCredentialsException("missing field `Expiration`")
126+
JsonCredentialsResponse.SessionCredentials(accessKeyId!!, secretAccessKey!!, sessionToken!!, expiration!!)
127+
}
128+
else -> JsonCredentialsResponse.Error(code!!, message)
129+
}
130+
}

aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/ImdsClient.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import aws.sdk.kotlin.runtime.http.engine.crt.CrtHttpEngine
1515
import aws.sdk.kotlin.runtime.http.middleware.ServiceEndpointResolver
1616
import aws.sdk.kotlin.runtime.http.middleware.UserAgent
1717
import aws.smithy.kotlin.runtime.client.ExecutionContext
18+
import aws.smithy.kotlin.runtime.client.SdkClientOption
19+
import aws.smithy.kotlin.runtime.client.SdkLogMode
1820
import aws.smithy.kotlin.runtime.http.*
1921
import aws.smithy.kotlin.runtime.http.engine.HttpClientEngine
2022
import aws.smithy.kotlin.runtime.http.operation.*
@@ -56,6 +58,7 @@ public class ImdsClient private constructor(builder: Builder) : Closeable {
5658
private val tokenTtl: Duration = builder.tokenTtl
5759
private val clock: Clock = builder.clock
5860
private val platformProvider: PlatformProvider = builder.platformProvider
61+
private val sdkLogMode: SdkLogMode = builder.sdkLogMode
5962
private val httpClient: SdkHttpClient
6063

6164
init {
@@ -130,6 +133,7 @@ public class ImdsClient private constructor(builder: Builder) : Closeable {
130133
service = SERVICE
131134
// artifact of re-using ServiceEndpointResolver middleware
132135
set(AwsClientOption.Region, "not-used")
136+
set(SdkClientOption.LogMode, sdkLogMode)
133137
}
134138
}
135139
middleware.forEach { it.install(op) }
@@ -162,6 +166,11 @@ public class ImdsClient private constructor(builder: Builder) : Closeable {
162166
*/
163167
public var tokenTtl: Duration = Duration.seconds(DEFAULT_TOKEN_TTL_SECONDS)
164168

169+
/**
170+
* Configure the [SdkLogMode] used by the client
171+
*/
172+
public var sdkLogMode: SdkLogMode = SdkLogMode.Default
173+
165174
/**
166175
* The HTTP engine to use to make requests with. This is here to facilitate testing and can otherwise be ignored
167176
*/

aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/config/imds/TokenMiddleware.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,13 @@ internal class TokenMiddleware(config: Config) : Feature {
8686
val expires = clock.now() + Duration.seconds(ttl)
8787
Token(token, expires)
8888
}
89-
else -> throw EC2MetadataError(call.response.status.value, "Failed to retrieve IMDS token")
89+
else -> {
90+
val message = when (call.response.status) {
91+
HttpStatusCode.Forbidden -> "Request forbidden: IMDS is disabled or the caller has insufficient permissions."
92+
else -> "Failed to retrieve IMDS token"
93+
}
94+
throw EC2MetadataError(call.response.status.value, message)
95+
}
9096
}
9197
} finally {
9298
call.complete()

aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/EnvironmentCredentialsProviderTest.kt

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55

66
package aws.sdk.kotlin.runtime.auth.credentials
77

8-
import aws.sdk.kotlin.runtime.ConfigurationException
98
import aws.sdk.kotlin.runtime.config.AwsSdkSetting
109
import aws.sdk.kotlin.runtime.testing.runSuspendTest
10+
import io.kotest.matchers.string.shouldContain
1111
import kotlin.test.Test
1212
import kotlin.test.assertEquals
1313
import kotlin.test.assertFailsWith
@@ -36,15 +36,15 @@ class EnvironmentCredentialsProviderTest {
3636

3737
@Test
3838
fun `it should throw an exception on missing access key`(): Unit = runSuspendTest {
39-
assertFailsWith<ConfigurationException> {
39+
assertFailsWith<ProviderConfigurationException> {
4040
provider(AwsSdkSetting.AwsSecretAccessKey.environmentVariable to "def").getCredentials()
41-
}
41+
}.message.shouldContain("Missing value for environment variable `AWS_ACCESS_KEY_ID`")
4242
}
4343

4444
@Test
4545
fun `it should throw an exception on missing secret key`(): Unit = runSuspendTest {
46-
assertFailsWith<ConfigurationException> {
46+
assertFailsWith<ProviderConfigurationException> {
4747
provider(AwsSdkSetting.AwsAccessKeyId.environmentVariable to "abc").getCredentials()
48-
}
48+
}.message.shouldContain("Missing value for environment variable `AWS_SECRET_ACCESS_KEY`")
4949
}
5050
}

0 commit comments

Comments
 (0)