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
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,198 @@

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

import aws.sdk.kotlin.crt.auth.credentials.build
import aws.sdk.kotlin.runtime.crt.SdkDefaultIO
import aws.sdk.kotlin.crt.auth.credentials.EcsCredentialsProvider as EcsCredentialsProviderCrt
import aws.sdk.kotlin.runtime.config.AwsSdkSetting
import aws.sdk.kotlin.runtime.config.AwsSdkSetting.AwsContainerCredentialsRelativeUri
import aws.sdk.kotlin.runtime.config.resolve
import aws.sdk.kotlin.runtime.http.engine.crt.CrtHttpEngine
import aws.smithy.kotlin.runtime.ServiceException
import aws.smithy.kotlin.runtime.client.ExecutionContext
import aws.smithy.kotlin.runtime.http.*
import aws.smithy.kotlin.runtime.http.engine.HttpClientEngine
import aws.smithy.kotlin.runtime.http.middleware.ResolveEndpoint
import aws.smithy.kotlin.runtime.http.middleware.Retry
import aws.smithy.kotlin.runtime.http.operation.*
import aws.smithy.kotlin.runtime.http.request.HttpRequestBuilder
import aws.smithy.kotlin.runtime.http.request.header
import aws.smithy.kotlin.runtime.http.response.HttpResponse
import aws.smithy.kotlin.runtime.io.Closeable
import aws.smithy.kotlin.runtime.logging.Logger
import aws.smithy.kotlin.runtime.retries.RetryDirective
import aws.smithy.kotlin.runtime.retries.RetryErrorType
import aws.smithy.kotlin.runtime.retries.RetryPolicy
import aws.smithy.kotlin.runtime.retries.impl.*
import aws.smithy.kotlin.runtime.serde.json.JsonDeserializer
import aws.smithy.kotlin.runtime.time.TimestampFormat
import aws.smithy.kotlin.runtime.util.Platform
import aws.smithy.kotlin.runtime.util.PlatformEnvironProvider

/**
* A provider that gets credentials from an ECS environment
* The elastic container metadata service endpoint that should be called by the [aws.sdk.kotlin.runtime.auth.credentials.EcsCredentialsProvider]
* when loading data from the container metadata service.
*
* @param host The host component of the URL to query credentials from
* @param pathAndQuery The path and query components of the URI, concatenated, to query credentials from
* @param authToken The token to pass to ECS credential service
* This is not used if the [AwsContainerCredentialsRelativeUri] is not specified.
*/
public class EcsCredentialsProvider public constructor(
host: String? = null,
pathAndQuery: String? = null,
authToken: String? = null,
) : CrtCredentialsProvider {
override val crtProvider: EcsCredentialsProviderCrt = EcsCredentialsProviderCrt.build {
clientBootstrap = SdkDefaultIO.ClientBootstrap
tlsContext = SdkDefaultIO.TlsContext
this.host = host
this.pathAndQuery = pathAndQuery
this.authToken = authToken
private const val AWS_CONTAINER_SERVICE_ENDPOINT = "http://169.254.170.2"

/**
* A [CredentialsProvider] that sources credentials from a local metadata service.
*
* This provider is frequently used with an AWS-provided credentials service such as Amazon Container Service (ECS).
* However, it is possible to use environment variables to configure this provider to use any local metadata service.
*
* For more information on configuring ECS credentials see [IAM Roles for tasks](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/task-iam-roles.html)
*
*/
public class EcsCredentialsProvider internal constructor(
private val platform: PlatformEnvironProvider,
private val httpClientEngine: HttpClientEngine
) : CredentialsProvider, Closeable {

public constructor() : this(Platform, CrtHttpEngine())

private val retryMiddleware = run {
Copy link
Contributor

Choose a reason for hiding this comment

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

nice

val tokenBucket = StandardRetryTokenBucket(StandardRetryTokenBucketOptions.Default)
val delayProvider = ExponentialBackoffWithJitter(ExponentialBackoffWithJitterOptions.Default)
val strategy = StandardRetryStrategy(StandardRetryStrategyOptions.Default, tokenBucket, delayProvider)
val policy = EcsCredentialsRetryPolicy()
Retry<Credentials>(strategy, policy)
}

override suspend fun getCredentials(): Credentials {
val logger = Logger.getLogger<EcsCredentialsProvider>()
val authToken = AwsSdkSetting.AwsContainerAuthorizationToken.resolve(platform)
val relativeUri = AwsSdkSetting.AwsContainerCredentialsRelativeUri.resolve(platform)
val fullUri = AwsSdkSetting.AwsContainerCredentialsFullUri.resolve(platform)

val url = when {
relativeUri?.isBlank() == false -> validateRelativeUri(relativeUri)
fullUri?.isBlank() == false -> validateFullUri(fullUri)
else -> throw ProviderConfigurationException("Container credentials URI not set")
}

val op = SdkHttpOperation.build<Unit, Credentials> {
serializer = EcsCredentialsSerializer(authToken)
deserializer = EcsCredentialsDeserializer()
context {
operationName = "EcsCredentialsProvider"
service = "n/a"
}
}

op.install(ResolveEndpoint(resolver = { Endpoint(url) }))
op.install(retryMiddleware)

logger.debug { "retrieving container credentials" }
val client = sdkHttpClient(httpClientEngine, manageEngine = false)
val creds = try {
op.roundTrip(client, Unit)
} catch (ex: Exception) {
logger.debug { "failed to obtain credentials from container metadata service" }
throw when (ex) {
is CredentialsProviderException -> ex
else -> CredentialsProviderException("Failed to get credentials from container metadata service", ex)
}
} finally {
client.close()
}

logger.debug { "obtained credentials from container metadata service; expiration=${creds.expiration?.format(TimestampFormat.ISO_8601)}" }

return creds
}

/**
* Validate that the [relativeUri] can be combined with the static ECS endpoint to form a valid URL
*/
private fun validateRelativeUri(relativeUri: String): Url = try {
Url.parse("${AWS_CONTAINER_SERVICE_ENDPOINT}$relativeUri")
} catch (ex: Exception) {
throw ProviderConfigurationException("Invalid relativeUri `$relativeUri`", ex)
}

/**
* Validate that [uri] is valid to be used as a full provider URI
*
* Either:
* 1. The URL uses `https
* 2. The URL refers to a loopback device. If a URL contains a domain name instead of an IP address a DNS lookup
* will be performed. ALL resolved IP addresses MUST refer to a loopback interface.
*
* @return the validated URL
*/
private suspend fun validateFullUri(uri: String): Url {
// full URI requires verification either https OR that the host resolves to loopback device
val url = try {
Url.parse(uri)
} catch (ex: Exception) {
throw ProviderConfigurationException("Invalid fullUri `$uri`", ex)
}

if (url.scheme == Protocol.HTTPS) return url

// TODO - validate loopback via DNS resolution instead of fixed set. Custom host names (including localhost) that
Copy link
Contributor

Choose a reason for hiding this comment

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

question

will this require some KMP work?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah that's why it isn't implemented. We need the ability to actually resolve a host to something like List<InetAddress> and verify that each one is in fact the loopback device (e.g. InetAddres.isLoopBackDevice(): Boolean).

ECS itself only uses the relative URI, this only comes into play when the full URI is set (and not using https). I believe greengrass is the primary user of this for something but I don't recall what. Seems safe to wait until it's needed. I created #476 to track it.

// resolve to loopback won't work until then. ALL resolved addresses MUST resolve to the loopback device
val allowedHosts = setOf("127.0.0.1", "[::1]")

if (url.host !in allowedHosts) {
throw ProviderConfigurationException(
"The container credentials full URI ($uri) has an invalid host. Host can only be one of [${allowedHosts.joinToString()}]."
)
}
return url
}

override fun close() {
httpClientEngine.close()
}
}
private class EcsCredentialsDeserializer : HttpDeserialize<Credentials> {
override suspend fun deserialize(context: ExecutionContext, response: HttpResponse): Credentials {
val payload = response.body.readAll() ?: throw CredentialsProviderException("HTTP credentials response did not contain a payload")
val deserializer = JsonDeserializer(payload)
return when (val resp = deserializeJsonCredentials(deserializer)) {
is JsonCredentialsResponse.SessionCredentials -> Credentials(
resp.accessKeyId,
resp.secretAccessKey,
resp.sessionToken,
resp.expiration
)
is JsonCredentialsResponse.Error -> throw CredentialsProviderException("Error retrieving credentials from container service: code=${resp.code}; message=${resp.message}")
}
}
}

private class EcsCredentialsSerializer(
private val authToken: String? = null
) : HttpSerialize<Unit> {
override suspend fun serialize(context: ExecutionContext, input: Unit): HttpRequestBuilder {
val builder = HttpRequestBuilder()
builder.url.path
builder.header("Accept", "application/json")
builder.header("Accept-Encoding", "identity")
if (authToken != null) {
builder.header("Authorization", authToken)
}
return builder
}
}

internal class EcsCredentialsRetryPolicy : RetryPolicy<Any?> {
override fun evaluate(result: Result<Any?>): RetryDirective = when {
result.isSuccess -> RetryDirective.TerminateAndSucceed
else -> evaluate(result.exceptionOrNull()!!)
Copy link
Contributor

Choose a reason for hiding this comment

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

nit

it seems a little weird to call an ...orNull() function and then !! it. Is there a reason this is needed?

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 I agree it's a little weird but there is no function on Result that provides the exception or throws. The success case has functions like getOrThrow() but nothing like that for the error case. No smart casting to be had either :(

Seems like something is missing from Result to me but this should be safe since we know it's the error case.

}

private fun evaluate(throwable: Throwable): RetryDirective = when (throwable) {
is ServiceException -> {
val httpResp = throwable.sdkErrorMetadata.protocolResponse as? HttpResponse
val status = httpResp?.status
if (status?.category() == HttpStatusCode.Category.SERVER_ERROR) {
RetryDirective.RetryError(RetryErrorType.ServerSide)
} else {
RetryDirective.TerminateAndFail
}
}
else -> RetryDirective.TerminateAndFail
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,23 @@ public sealed class AwsSdkSetting<T>(
* The AWS web identity token file path
*/
public object AwsWebIdentityTokenFile : AwsSdkSetting<String>("AWS_WEB_IDENTITY_TOKEN_FILE", "aws.webIdentityTokenFile")

/**
* The elastic container metadata service path that should be called by the [aws.sdk.kotlin.runtime.auth.credentials.EcsCredentialsProvider]
* when loading credentials from the container metadata service.
*/
public object AwsContainerCredentialsRelativeUri : AwsSdkSetting<String> ("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", "aws.containerCredentialsPath", null)

/**
* The full URI path to a localhost metadata service to be used. This is ignored if
* [AwsContainerCredentialsRelativeUri] is set.
*/
public object AwsContainerCredentialsFullUri : AwsSdkSetting<String>("AWS_CONTAINER_CREDENTIALS_FULL_URI", "aws.containerCredentialsFullUri", null)

/**
* An authorization token to pass to a container metadata service.
*/
public object AwsContainerAuthorizationToken : AwsSdkSetting<String>("AWS_CONTAINER_AUTHORIZATION_TOKEN", "aws.containerAuthorizationToken", null)
}

/**
Expand Down
Loading