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
81 changes: 81 additions & 0 deletions aws-runtime/aws-config/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
* Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
* SPDX-License-Identifier: Apache-2.0.
*/
import aws.sdk.kotlin.gradle.codegen.dsl.smithyKotlinPlugin

plugins {
id("aws.sdk.kotlin.codegen")
}

description = "Support for AWS configuration"
extra["moduleName"] = "aws.sdk.kotlin.runtime.config"
Expand Down Expand Up @@ -31,6 +36,8 @@ kotlin {
implementation("aws.sdk.kotlin.crt:aws-crt-kotlin:$crtKotlinVersion")
implementation(project(":aws-runtime:crt-util"))

// additional dependencies required by generated sso provider
implementation(project(":aws-runtime:protocols:aws-json-protocols"))
}
}
commonTest {
Expand All @@ -55,3 +62,77 @@ kotlin {
}
}
}

fun awsModelFile(name: String): String =
rootProject.file("codegen/sdk/aws-models/$name").absolutePath

codegen {
val basePackage = "aws.sdk.kotlin.runtime.auth.credentials.internal"

projections {
// generate an sso client
create("sso-credentials-provider") {
imports = listOf(
awsModelFile("sso.2019-06-10.json")
)

val serviceShape = "com.amazonaws.sso#SWBPortalService"
smithyKotlinPlugin {
serviceShapeId = serviceShape
packageName = "${basePackage}.sso"
packageVersion = project.version.toString()
packageDescription = "Internal SSO credentials provider"
sdkId = "SSO"
buildSettings {
generateDefaultBuildFiles = false
generateFullProject = false
}
}

transforms = listOf(
"""
{
"name": "awsSdkKotlinIncludeOperations",
"args": {
"operations": [
"com.amazonaws.sso#GetRoleCredentials"
]
}
}
"""
)
}
}
}

/*
NOTE: We need the following tasks to depend on codegen for gradle caching/up-to-date checks to work correctly:

* `compileKotlinJvm` (Type=KotlinCompile)
* `compileKotlinMetadata` (Type=KotlinCompileCommon)
* `sourcesJar` and `jvmSourcesJar` (Type=org.gradle.jvm.tasks.Jar)
*/
val codegenTask = tasks.named("generateSmithyProjections")
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
dependsOn(codegenTask)
}

tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompileCommon> {
dependsOn(codegenTask)
}

tasks.withType<org.gradle.jvm.tasks.Jar> {
dependsOn(codegenTask)
}


codegen.projections.all {
// add this projected source dir to the common sourceSet
// NOTE - build.gradle.kts is still being generated, it's NOT used though
// TODO - we should probably either have a postProcessing spec or a plugin setting to not generate it to avoid confusion
val projectedSrcDir = projectionRootDir.resolve("src/main/kotlin")
kotlin.sourceSets.commonMain {
println("added $projectedSrcDir to common sourceSet")
kotlin.srcDir(projectedSrcDir)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@

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

import aws.sdk.kotlin.crt.auth.credentials.build
import aws.sdk.kotlin.runtime.config.CachedValue
import aws.sdk.kotlin.runtime.config.ExpiringValue
import aws.smithy.kotlin.runtime.logging.Logger
import aws.smithy.kotlin.runtime.time.Clock
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
import aws.sdk.kotlin.crt.auth.credentials.CachedCredentialsProvider as CachedCredentialsProviderCrt

private const val DEFAULT_CREDENTIALS_REFRESH_BUFFER_SECONDS = 10
private const val DEFAULT_CREDENTIALS_REFRESH_SECONDS = 60 * 15

/**
* Creates a provider that functions as a caching decorating of another provider.
* Creates a provider that functions as a caching decorator of another provider.
*
* Credentials sourced through this provider will be cached within it until their expiration time.
* When the cached credentials expire, new credentials will be fetched when next queried.
Expand All @@ -20,43 +25,37 @@ import aws.sdk.kotlin.crt.auth.credentials.CachedCredentialsProvider as CachedCr
*
* CachedProvider -> ProviderChain(EnvironmentProvider -> ProfileProvider -> ECS/EC2IMD etc...)
*
* @param source the provider to cache credentials results from
* @param expireCredentialsAfter the default expiration time period for sourced credentials. For a given set of
* cached credentials, the refresh time period will be the minimum of this time and any expiration timestamp on
* the credentials themselves.
* @param refreshBufferWindow amount of time before the actual credential expiration time when credentials are
* considered expired. For example, if credentials are expiring in 15 minutes, and the buffer time is 10 seconds,
* then any requests made after 14 minutes and 50 seconds will load new credentials. Defaults to 10 seconds.
* @param clock the source of time for this provider
*
* @return the newly-constructed credentials provider
*/
public class CachedCredentialsProvider private constructor(builder: Builder) : CrtCredentialsProvider {

@OptIn(ExperimentalTime::class)
override val crtProvider: CachedCredentialsProviderCrt = CachedCredentialsProviderCrt.build {
refreshTimeInMilliseconds = builder.refreshTime.inWholeMilliseconds

// FIXME - note this won't work until https://github.com/awslabs/aws-crt-java/issues/252 is resolved
source = builder.source?.let { CredentialsProviderCrtProxy(it) }
}

public companion object {
/**
* Construct a new [CachedCredentialsProvider] using the given [block]
*/
public fun build(block: Builder.() -> Unit): CachedCredentialsProvider = Builder().apply(block).build()
}

@OptIn(ExperimentalTime::class)
public class Builder {
/**
* The provider to cache credentials query results from
*/
public var source: CredentialsProvider? = null

/**
* An optional expiration time period for sourced credentials. For a given set of cached credentials,
* the refresh time period will be the minimum of this time and any expiration timestamp on the credentials
* themselves.
*/
public var refreshTime: Duration = Duration.ZERO

public fun build(): CachedCredentialsProvider {

requireNotNull(source) { "CachedCredentialsProvider requires a source provider to wrap" }
return CachedCredentialsProvider(this)
@OptIn(ExperimentalTime::class)
public class CachedCredentialsProvider(
private val source: CredentialsProvider,
private val expireCredentialsAfter: Duration = Duration.seconds(DEFAULT_CREDENTIALS_REFRESH_SECONDS),
refreshBufferWindow: Duration = Duration.seconds(DEFAULT_CREDENTIALS_REFRESH_BUFFER_SECONDS),
private val clock: Clock = Clock.System
) : CredentialsProvider {

private var cachedCredentials = CachedValue<Credentials>(null, bufferTime = refreshBufferWindow, clock)

override suspend fun getCredentials(): Credentials = cachedCredentials.getOrLoad {
val logger = Logger.getLogger<CachedCredentialsProvider>()
logger.trace { "refreshing credentials cache" }
val providerCreds = source.getCredentials()
if (providerCreds.expiration != null) {
ExpiringValue(providerCreds, providerCreds.expiration!!)
} else {
val expiration = clock.now() + expireCredentialsAfter
val creds = providerCreds.copy(expiration = expiration)
ExpiringValue(creds, expiration)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/*
* 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.ConfigurationException
import aws.sdk.kotlin.runtime.auth.credentials.internal.sso.SsoClient
import aws.sdk.kotlin.runtime.config.profile.normalizePath
import aws.smithy.kotlin.runtime.http.engine.HttpClientEngine
import aws.smithy.kotlin.runtime.serde.json.*
import aws.smithy.kotlin.runtime.time.Clock
import aws.smithy.kotlin.runtime.time.Instant
import aws.smithy.kotlin.runtime.util.*

/**
* [CredentialsProvider] that uses AWS Single Sign-On (AWS SSO) to source credentials. The
* provider is expected to be configured for the AWS Region where the AWS SSO user portal is hosted.
*
* The provider does not initiate or perform the AWS SSO login flow. It is expected that you have
* already performed the SSO login flow using (e.g. using the AWS CLI `aws sso login`). The provider
* expects a valid non-expired access token for the AWS SSO user portal URL in `~/.aws/sso/cache`.
* If a cached token is not found, it is expired, or the file is malformed an exception will be thrown.
*
*
* **Instantiating AWS SSO provider directly**
*
* You can programmatically construct the AWS SSO provider in your application, and provide the necessary
* information to load and retrieve temporary credentials using an access token from `~/.aws/sso/cache`.
*
* ```
* val source = SsoCredentialsProvider(
* accountId = "123456789",
* roleName = "SsoReadOnlyRole",
* startUrl = "https://my-sso-portal.awsapps.com/start",
* ssoRegion = "us-east-2"
* )
*
* // Wrap the provider with a caching provider to cache the credentials until their expiration time
* val ssoProvider = CachedCredentialsProvider(source)
* ```
* It is important that you wrap the provider with [CachedCredentialsProvider] if you are programatically constructing
* the provider directly. This prevents your application from accessing the cached access token and requesting new
* credentials each time the provider is used to source credentials.
*
*
* **Additional Resources**
* * [Configuring the AWS CLI to use AWS Single Sign-On](https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html)
* * [AWS Single Sign-On User Guide](https://docs.aws.amazon.com/singlesignon/latest/userguide/what-is.html)
*/
public class SsoCredentialsProvider public constructor(
/**
* The AWS account ID that temporary AWS credentials will be resolved for
*/
public val accountId: String,

/**
* The IAM role in the AWS account that temporary AWS credentials will be resolved for
*/
public val roleName: String,

/**
* The start URL (also known as the "User Portal URL") provided by the SSO service
*/
public val startUrl: String,

/**
* The AWS region where the SSO directory for the given [startUrl] is hosted.
*/
public val ssoRegion: String,

/**
* The [HttpClientEngine] to use when making requests to the AWS SSO service
*/
private val httpClientEngine: HttpClientEngine? = null,

/**
* The platform provider
*/
private val platformProvider: PlatformProvider = Platform,

/**
* The source of time for the provider
*/
private val clock: Clock = Clock.System

) : CredentialsProvider {

override suspend fun getCredentials(): Credentials {

val token = loadTokenFile()

val client = SsoClient {
region = ssoRegion
httpClientEngine = this@SsoCredentialsProvider.httpClientEngine
}

val resp = try {
client.getRoleCredentials {
accountId = this@SsoCredentialsProvider.accountId
roleName = this@SsoCredentialsProvider.roleName
accessToken = token.accessToken
}
} catch (ex: Exception) {
throw CredentialsNotLoadedException("GetRoleCredentials operation failed", ex)
} finally {
client.close()
}

val roleCredentials = resp.roleCredentials ?: throw CredentialsProviderException("Expected SSO roleCredentials to not be null")

return Credentials(
accessKeyId = checkNotNull(roleCredentials.accessKeyId) { "Expected accessKeyId in SSO roleCredentials response" },
secretAccessKey = checkNotNull(roleCredentials.secretAccessKey) { "Expected secretAccessKey in SSO roleCredentials response" },
sessionToken = roleCredentials.sessionToken,
expiration = Instant.fromEpochSeconds(roleCredentials.expiration)
)
}

private suspend fun loadTokenFile(): SsoToken {
val key = getCacheFilename(startUrl)
val bytes = with(platformProvider) {
val defaultCacheLocation = normalizePath(filepath("~", ".aws", "sso", "cache"), this)
readFileOrNull(filepath(defaultCacheLocation, key))
} ?: throw ProviderConfigurationException("Invalid or missing SSO session cache. Run `aws sso login` to initiate a new SSO session")

val token = deserializeSsoToken(bytes)
val now = clock.now()
if (now > token.expiresAt) throw ProviderConfigurationException("The SSO session has expired. To refresh this SSO session run `aws sso login` with the corresponding profile.")

return token
}
}

internal fun PlatformProvider.filepath(vararg parts: String): String = parts.joinToString(separator = filePathSeparator)

internal fun getCacheFilename(url: String): String {
val sha1HexDigest = url.encodeToByteArray().sha1().encodeToHex()
return "$sha1HexDigest.json"
}

internal data class SsoToken(
val accessToken: String,
val expiresAt: Instant,
val region: String? = null,
val startUrl: String? = null
)

internal fun deserializeSsoToken(json: ByteArray): SsoToken {
val lexer = jsonStreamReader(json)

var accessToken: String? = null
var expiresAtRfc3339: String? = null
var region: String? = null
var startUrl: String? = null

try {
lexer.nextTokenOf<JsonToken.BeginObject>()
loop@while (true) {
when (val token = lexer.nextToken()) {
is JsonToken.EndObject -> break@loop
is JsonToken.Name -> when (token.value) {
"accessToken" -> accessToken = lexer.nextTokenOf<JsonToken.String>().value
"expiresAt" -> expiresAtRfc3339 = lexer.nextTokenOf<JsonToken.String>().value
"region" -> region = lexer.nextTokenOf<JsonToken.String>().value
"startUrl" -> startUrl = lexer.nextTokenOf<JsonToken.String>().value
else -> lexer.skipNext()
}
else -> error("expected either key or end of object")
}
}
} catch (ex: Exception) {
throw InvalidSsoTokenException("invalid cached SSO token", ex)
}

if (accessToken == null) throw InvalidSsoTokenException("missing `accessToken`")
val expiresAt = expiresAtRfc3339?.let { Instant.fromIso8601(it) } ?: throw InvalidSsoTokenException("missing `expiresAt`")

return SsoToken(
accessToken,
expiresAt,
region,
startUrl
)
}

/**
* An error associated with a cached SSO token from `~/.aws/sso/cache/`
*/
public class InvalidSsoTokenException(message: String, cause: Throwable? = null) : ConfigurationException(message, cause)
Loading