Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions aws-runtime/aws-config/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ kotlin {
implementation(project(":aws-runtime:http-client-engine-crt"))
implementation(project(":aws-runtime:aws-http"))

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


// credential providers
implementation("aws.sdk.kotlin.crt:aws-crt-kotlin:$crtKotlinVersion")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

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

import aws.sdk.kotlin.runtime.ConfigurationException
import aws.sdk.kotlin.runtime.config.AwsSdkSetting
import aws.smithy.kotlin.runtime.util.Platform

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

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

override suspend fun getCredentials(): Credentials = Credentials(
accessKeyId = requireEnv(AwsSdkSetting.AwsAccessKeyId.environmentVariable),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/*
* 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.ClientException
import aws.sdk.kotlin.runtime.ConfigurationException

/**
* No credentials were available from this [CredentialsProvider]
*/
public class CredentialsNotLoadedException(message: String?, cause: Throwable? = null) :
ClientException(message ?: "The provider could not provide credentials or required configuration was not set", cause)

/**
* The [CredentialsProvider] was given an invalid configuration (e.g. invalid aws configuration file, invalid IMDS endpoint, etc)
*/
public class ProviderConfigurationException(message: String, cause: Throwable? = null) : ConfigurationException(message, cause)

/**
* The [CredentialsProvider] experienced an error during credentials resolution
*/
public class CredentialsProviderException(message: String, cause: Throwable? = null) : ClientException(message, cause)
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* 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.EC2MetadataError
import aws.sdk.kotlin.runtime.config.imds.ImdsClient
import aws.sdk.kotlin.runtime.config.resolve
import aws.smithy.kotlin.runtime.http.HttpStatusCode
import aws.smithy.kotlin.runtime.io.Closeable
import aws.smithy.kotlin.runtime.logging.Logger
import aws.smithy.kotlin.runtime.serde.json.JsonDeserializer
import aws.smithy.kotlin.runtime.util.Platform
import aws.smithy.kotlin.runtime.util.PlatformEnvironProvider
import aws.smithy.kotlin.runtime.util.asyncLazy

private const val CREDENTIALS_BASE_PATH: String = "/latest/meta-data/iam/security-credentials"
private const val CODE_ASSUME_ROLE_UNAUTHORIZED_ACCESS: String = "AssumeRoleUnauthorizedAccess"

/**
* [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)
* configured.
*
* 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
* `<IMDS_BASE_URL>/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
* @param platformProvider the [PlatformEnvironProvider] instance
*/
public class ImdsCredentialsProvider(
private val profileOverride: String? = null,
private val client: Lazy<ImdsClient> = lazy { ImdsClient() },
private val platformProvider: PlatformEnvironProvider = Platform
) : CredentialsProvider, Closeable {
private val logger = Logger.getLogger<ImdsCredentialsProvider>()

private val profile = asyncLazy {
if (profileOverride != null) return@asyncLazy profileOverride
loadProfile()
}

override suspend fun getCredentials(): Credentials {
if (AwsSdkSetting.AwsEc2MetadataDisabled.resolve(platformProvider) == true) {
throw CredentialsNotLoadedException("AWS EC2 metadata is explicitly disabled; credentials not loaded")
}

val profileName = try {
profile.get()
} catch (ex: Exception) {
throw CredentialsProviderException("failed to load instance profile", ex)
}

val payload = client.value.get("$CREDENTIALS_BASE_PATH/$profileName")
val deserializer = JsonDeserializer(payload.encodeToByteArray())

return when (val resp = deserializeJsonCredentials(deserializer)) {
is JsonCredentialsResponse.SessionCredentials -> Credentials(
resp.accessKeyId,
resp.secretAccessKey,
resp.sessionToken,
resp.expiration
)
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 (client.isInitialized()) {
client.value.close()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comment

it would be nice to get lazy eval without having the additional value access. I don't see a way to do it offhand but 🤷

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ya you can't have properties/delegated properties be suspend so I don't see a way around it

}
}

private suspend fun loadProfile(): String {
return try {
client.value.get(CREDENTIALS_BASE_PATH)
} catch (ex: EC2MetadataError) {
if (ex.statusCode == HttpStatusCode.NotFound.value) {
logger.info { "Received 404 from IMDS when loading profile information. Hint: This instance may not have an IAM role associated." }
}
throw ex
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/*
* 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.ClientException
import aws.smithy.kotlin.runtime.serde.*
import aws.smithy.kotlin.runtime.serde.json.JsonSerialName
import aws.smithy.kotlin.runtime.time.Instant

/**
* Exception thrown when credentials from response do not contain valid credentials or malformed JSON
*/
public class InvalidJsonCredentialsException(message: String, cause: Throwable? = null) : ClientException(message, cause)

/**
* Common response elements for multiple HTTP credential providers (e.g. IMDS and ECS)
*/
internal sealed class JsonCredentialsResponse {
/**
* Credentials that can expire
*/
data class SessionCredentials(
val accessKeyId: String,
val secretAccessKey: String,
val sessionToken: String,
val expiration: Instant,
) : JsonCredentialsResponse()

// TODO - add support for static credentials
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question

these will come in subsequent PR associated w/ the imds client?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not as much to do with IMDS so much as other JSON based credential responses. IMDS, STS, ECS all have the same JSON response structure (or rather are subsets of one another)

// {
// "AccessKeyId" : "MUA...",
// "SecretAccessKey" : "/7PC5om...."
// }

// TODO - add support for assume role credentials
// {
// // fields to construct STS client:
// "Region": "sts-region-name",
// "AccessKeyId" : "MUA...",
// "Expiration" : "2016-02-25T06:03:31Z", // optional
// "SecretAccessKey" : "/7PC5om....",
// "Token" : "AQoDY....=", // optional
// // fields controlling the STS role:
// "RoleArn": "...", // required
// "RoleSessionName": "...", // required
// // and also: DurationSeconds, ExternalId, SerialNumber, TokenCode, Policy
// ...
// }

/**
* Response successfully parsed as an error response
*/
data class Error(val code: String, val message: String?) : JsonCredentialsResponse()
}

/**
* In general, the document looks something like:
*
* ```
* {
* "Code" : "Success",
* "LastUpdated" : "2019-05-28T18:03:09Z",
* "Type" : "AWS-HMAC",
* "AccessKeyId" : "...",
* "SecretAccessKey" : "...",
* "Token" : "...",
* "Expiration" : "2019-05-29T00:21:43Z"
* }
* ```
*/
internal suspend fun deserializeJsonCredentials(deserializer: Deserializer): JsonCredentialsResponse {
val CODE_DESCRIPTOR = SdkFieldDescriptor(SerialKind.String, JsonSerialName("Code"))
val ACCESS_KEY_ID_DESCRIPTOR = SdkFieldDescriptor(SerialKind.String, JsonSerialName("AccessKeyId"))
val SECRET_ACCESS_KEY_ID_DESCRIPTOR = SdkFieldDescriptor(SerialKind.String, JsonSerialName("SecretAccessKey"))
val SESSION_TOKEN_DESCRIPTOR = SdkFieldDescriptor(SerialKind.String, JsonSerialName("Token"))
val EXPIRATION_DESCRIPTOR = SdkFieldDescriptor(SerialKind.Timestamp, JsonSerialName("Expiration"))
val MESSAGE_DESCRIPTOR = SdkFieldDescriptor(SerialKind.String, JsonSerialName("Message"))

val OBJ_DESCRIPTOR = SdkObjectDescriptor.build {
field(CODE_DESCRIPTOR)
field(ACCESS_KEY_ID_DESCRIPTOR)
field(SECRET_ACCESS_KEY_ID_DESCRIPTOR)
field(SESSION_TOKEN_DESCRIPTOR)
field(EXPIRATION_DESCRIPTOR)
field(MESSAGE_DESCRIPTOR)
}

var code: String? = null
var accessKeyId: String? = null
var secretAccessKey: String? = null
var sessionToken: String? = null
var expiration: Instant? = null
var message: String? = null

try {
deserializer.deserializeStruct(OBJ_DESCRIPTOR) {
loop@while (true) {
when (findNextFieldIndex()) {
CODE_DESCRIPTOR.index -> code = deserializeString()
ACCESS_KEY_ID_DESCRIPTOR.index -> accessKeyId = deserializeString()
SECRET_ACCESS_KEY_ID_DESCRIPTOR.index -> secretAccessKey = deserializeString()
SESSION_TOKEN_DESCRIPTOR.index -> sessionToken = deserializeString()
EXPIRATION_DESCRIPTOR.index -> expiration = Instant.fromIso8601(deserializeString())

// error responses
MESSAGE_DESCRIPTOR.index -> message = deserializeString()
null -> break@loop
else -> skipValue()
}
}
}
} catch (ex: DeserializationException) {
throw InvalidJsonCredentialsException("invalid JSON credentials response", ex)
}

return when (code?.lowercase()) {
// IMDS does not appear to reply with `Code` missing but documentation indicates it may be possible
"success", null -> {
if (accessKeyId == null) throw InvalidJsonCredentialsException("missing field `AccessKeyId`")
if (secretAccessKey == null) throw InvalidJsonCredentialsException("missing field `SecretAccessKey`")
if (sessionToken == null) throw InvalidJsonCredentialsException("missing field `Token`")
if (expiration == null) throw InvalidJsonCredentialsException("missing field `Expiration`")
JsonCredentialsResponse.SessionCredentials(accessKeyId!!, secretAccessKey!!, sessionToken!!, expiration!!)
}
else -> JsonCredentialsResponse.Error(code!!, message)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import aws.sdk.kotlin.runtime.http.engine.crt.CrtHttpEngine
import aws.sdk.kotlin.runtime.http.middleware.ServiceEndpointResolver
import aws.sdk.kotlin.runtime.http.middleware.UserAgent
import aws.smithy.kotlin.runtime.client.ExecutionContext
import aws.smithy.kotlin.runtime.client.SdkClientOption
import aws.smithy.kotlin.runtime.client.SdkLogMode
import aws.smithy.kotlin.runtime.http.*
import aws.smithy.kotlin.runtime.http.engine.HttpClientEngine
import aws.smithy.kotlin.runtime.http.operation.*
Expand Down Expand Up @@ -56,6 +58,7 @@ public class ImdsClient private constructor(builder: Builder) : Closeable {
private val tokenTtl: Duration = builder.tokenTtl
private val clock: Clock = builder.clock
private val platformProvider: PlatformProvider = builder.platformProvider
private val sdkLogMode: SdkLogMode = builder.sdkLogMode
private val httpClient: SdkHttpClient

init {
Expand Down Expand Up @@ -130,6 +133,7 @@ public class ImdsClient private constructor(builder: Builder) : Closeable {
service = SERVICE
// artifact of re-using ServiceEndpointResolver middleware
set(AwsClientOption.Region, "not-used")
set(SdkClientOption.LogMode, sdkLogMode)
}
}
middleware.forEach { it.install(op) }
Expand Down Expand Up @@ -162,6 +166,11 @@ public class ImdsClient private constructor(builder: Builder) : Closeable {
*/
public var tokenTtl: Duration = Duration.seconds(DEFAULT_TOKEN_TTL_SECONDS)

/**
* Configure the [SdkLogMode] used by the client
*/
public var sdkLogMode: SdkLogMode = SdkLogMode.Default

/**
* The HTTP engine to use to make requests with. This is here to facilitate testing and can otherwise be ignored
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,13 @@ internal class TokenMiddleware(config: Config) : Feature {
val expires = clock.now() + Duration.seconds(ttl)
Token(token, expires)
}
else -> throw EC2MetadataError(call.response.status.value, "Failed to retrieve IMDS token")
else -> {
val message = when (call.response.status) {
HttpStatusCode.Forbidden -> "Request forbidden: IMDS is disabled or the caller has insufficient permissions."
else -> "Failed to retrieve IMDS token"
}
throw EC2MetadataError(call.response.status.value, message)
}
}
} finally {
call.complete()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@

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

import aws.sdk.kotlin.runtime.ConfigurationException
import aws.sdk.kotlin.runtime.config.AwsSdkSetting
import aws.sdk.kotlin.runtime.testing.runSuspendTest
import io.kotest.matchers.string.shouldContain
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
Expand Down Expand Up @@ -36,15 +36,15 @@ class EnvironmentCredentialsProviderTest {

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

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