diff --git a/.changes/930c7904-1735-466b-9acc-c46e960c26c9.json b/.changes/930c7904-1735-466b-9acc-c46e960c26c9.json new file mode 100644 index 00000000000..eef26271835 --- /dev/null +++ b/.changes/930c7904-1735-466b-9acc-c46e960c26c9.json @@ -0,0 +1,5 @@ +{ + "id": "930c7904-1735-466b-9acc-c46e960c26c9", + "type": "feature", + "description": "Adds a new credentials provider for AWS Login token authentication" +} \ No newline at end of file diff --git a/.github/workflows/minor-version-bump.yml b/.github/workflows/minor-version-bump.yml index ef10fe70d9c..9998cf0459a 100644 --- a/.github/workflows/minor-version-bump.yml +++ b/.github/workflows/minor-version-bump.yml @@ -8,4 +8,4 @@ jobs: runs-on: ubuntu-latest steps: - name: Minor version bump check - uses: awslabs/aws-kotlin-repo-tools/.github/actions/minor-version-bump@main + uses: aws/aws-kotlin-repo-tools/.github/actions/minor-version-bump@main diff --git a/.github/workflows/service-ci.yml b/.github/workflows/service-ci.yml index 163b1209fd1..2b13f2d200f 100644 --- a/.github/workflows/service-ci.yml +++ b/.github/workflows/service-ci.yml @@ -28,7 +28,7 @@ jobs: env-vars-for-codebuild: GITHUB_REPOSITORY, UPLOAD, RELEASE_METRICS, IDENTIFIER - name: Process metrics - uses: awslabs/aws-kotlin-repo-tools/.github/actions/artifact-size-metrics/download-and-process@main + uses: aws/aws-kotlin-repo-tools/.github/actions/artifact-size-metrics/download-and-process@main with: download: 'true' @@ -49,4 +49,3 @@ concurrency: permissions: id-token: write contents: read - diff --git a/aws-runtime/aws-config/api/aws-config.api b/aws-runtime/aws-config/api/aws-config.api index 6d6878eb35f..6b7718887cb 100644 --- a/aws-runtime/aws-config/api/aws-config.api +++ b/aws-runtime/aws-config/api/aws-config.api @@ -92,11 +92,27 @@ public final class aws/sdk/kotlin/runtime/auth/credentials/InvalidJsonCredential public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V } +public final class aws/sdk/kotlin/runtime/auth/credentials/InvalidLoginTokenException : aws/sdk/kotlin/runtime/ConfigurationException { + public fun (Ljava/lang/String;Ljava/lang/Throwable;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V +} + public final class aws/sdk/kotlin/runtime/auth/credentials/InvalidSsoTokenException : aws/sdk/kotlin/runtime/ConfigurationException { public fun (Ljava/lang/String;Ljava/lang/Throwable;)V public synthetic fun (Ljava/lang/String;Ljava/lang/Throwable;ILkotlin/jvm/internal/DefaultConstructorMarker;)V } +public final class aws/sdk/kotlin/runtime/auth/credentials/LoginCredentialsProvider : aws/smithy/kotlin/runtime/auth/awscredentials/CloseableCredentialsProvider { + public fun (Ljava/lang/String;Ljava/lang/String;Laws/smithy/kotlin/runtime/http/engine/HttpClientEngine;Laws/smithy/kotlin/runtime/util/PlatformProvider;Laws/smithy/kotlin/runtime/time/Clock;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Laws/smithy/kotlin/runtime/http/engine/HttpClientEngine;Laws/smithy/kotlin/runtime/util/PlatformProvider;Laws/smithy/kotlin/runtime/time/Clock;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun close ()V + public final fun getHttpClient ()Laws/smithy/kotlin/runtime/http/engine/HttpClientEngine; + public final fun getLoginSession ()Ljava/lang/String; + public final fun getPlatformProvider ()Laws/smithy/kotlin/runtime/util/PlatformProvider; + public final fun getRegion ()Ljava/lang/String; + public fun resolve (Laws/smithy/kotlin/runtime/collections/Attributes;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + public final class aws/sdk/kotlin/runtime/auth/credentials/ProcessCredentialsProvider : aws/smithy/kotlin/runtime/auth/awscredentials/CredentialsProvider { public fun (Ljava/lang/String;Laws/smithy/kotlin/runtime/util/PlatformProvider;JJ)V public synthetic fun (Ljava/lang/String;Laws/smithy/kotlin/runtime/util/PlatformProvider;JJILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -237,6 +253,63 @@ public final class aws/sdk/kotlin/runtime/auth/credentials/internal/ManagedCrede public static final fun manage (Laws/smithy/kotlin/runtime/auth/awscredentials/CloseableCredentialsProvider;)Laws/smithy/kotlin/runtime/auth/awscredentials/CredentialsProvider; } +public abstract class aws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode { + public static final field Companion Laws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode$Companion; + public abstract fun getValue ()Ljava/lang/String; +} + +public final class aws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode$AuthcodeExpired : aws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode { + public static final field INSTANCE Laws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode$AuthcodeExpired; + public fun getValue ()Ljava/lang/String; + public fun toString ()Ljava/lang/String; +} + +public final class aws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode$Companion { + public final fun fromValue (Ljava/lang/String;)Laws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode; + public final fun values ()Ljava/util/List; +} + +public final class aws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode$InsufficientPermissions : aws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode { + public static final field INSTANCE Laws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode$InsufficientPermissions; + public fun getValue ()Ljava/lang/String; + public fun toString ()Ljava/lang/String; +} + +public final class aws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode$InvalidRequest : aws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode { + public static final field INSTANCE Laws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode$InvalidRequest; + public fun getValue ()Ljava/lang/String; + public fun toString ()Ljava/lang/String; +} + +public final class aws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode$SdkUnknown : aws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode { + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Laws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode$SdkUnknown; + public static synthetic fun copy$default (Laws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode$SdkUnknown;Ljava/lang/String;ILjava/lang/Object;)Laws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode$SdkUnknown; + public fun equals (Ljava/lang/Object;)Z + public fun getValue ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class aws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode$ServerError : aws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode { + public static final field INSTANCE Laws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode$ServerError; + public fun getValue ()Ljava/lang/String; + public fun toString ()Ljava/lang/String; +} + +public final class aws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode$TokenExpired : aws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode { + public static final field INSTANCE Laws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode$TokenExpired; + public fun getValue ()Ljava/lang/String; + public fun toString ()Ljava/lang/String; +} + +public final class aws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode$UserCredentialsChanged : aws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode { + public static final field INSTANCE Laws/sdk/kotlin/runtime/auth/credentials/internal/signin/model/OAuth2ErrorCode$UserCredentialsChanged; + public fun getValue ()Ljava/lang/String; + public fun toString ()Ljava/lang/String; +} + public abstract class aws/sdk/kotlin/runtime/config/AbstractAwsSdkClientFactory : aws/smithy/kotlin/runtime/client/AbstractSdkClientFactory { public fun ()V protected fun finalizeEnvironmentalConfig (Laws/smithy/kotlin/runtime/client/SdkClient$Builder;Laws/smithy/kotlin/runtime/util/LazyAsyncValue;Laws/smithy/kotlin/runtime/util/LazyAsyncValue;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; diff --git a/aws-runtime/aws-config/build.gradle.kts b/aws-runtime/aws-config/build.gradle.kts index 3d3759115d8..2e9506af3a3 100644 --- a/aws-runtime/aws-config/build.gradle.kts +++ b/aws-runtime/aws-config/build.gradle.kts @@ -76,6 +76,7 @@ dependencies { codegen(project(":codegen:aws-sdk-codegen")) codegen(libs.smithy.cli) codegen(libs.smithy.model) + codegen(libs.smithy.aws.smoke.test.model) } smithyBuild { @@ -188,6 +189,41 @@ smithyBuild { """, ) } + + create("signin-credentials-provider") { + imports = listOf( + awsModelFile("signin.json"), + ) + + val serviceShape = "com.amazonaws.signin#Signin" + smithyKotlinPlugin { + serviceShapeId = serviceShape + packageName = "$basePackage.signin" + packageVersion = project.version.toString() + packageDescription = "Internal Signin credentials provider" + sdkId = "Signin" + buildSettings { + generateDefaultBuildFiles = false + generateFullProject = false + } + apiSettings { + visibility = "internal" + } + } + + transforms = listOf( + """ + { + "name": "awsSmithyKotlinIncludeOperations", + "args": { + "operations": [ + "com.amazonaws.signin#CreateOAuth2Token" + ] + } + } + """, + ) + } } } diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/LoginCredentialsProvider.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/LoginCredentialsProvider.kt new file mode 100644 index 00000000000..2b71ec8c10c --- /dev/null +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/LoginCredentialsProvider.kt @@ -0,0 +1,96 @@ +/* + * 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.auth.credentials.internal.signin.SigninClient +import aws.sdk.kotlin.runtime.http.interceptors.businessmetrics.AwsBusinessMetric +import aws.sdk.kotlin.runtime.http.interceptors.businessmetrics.withBusinessMetric +import aws.smithy.kotlin.runtime.auth.awscredentials.CloseableCredentialsProvider +import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials +import aws.smithy.kotlin.runtime.auth.awscredentials.CredentialsProvider +import aws.smithy.kotlin.runtime.collections.Attributes +import aws.smithy.kotlin.runtime.http.engine.HttpClientEngine +import aws.smithy.kotlin.runtime.telemetry.logging.logger +import aws.smithy.kotlin.runtime.time.Clock +import aws.smithy.kotlin.runtime.util.PlatformProvider +import kotlinx.coroutines.runBlocking +import kotlin.coroutines.coroutineContext + +/** + * [CredentialsProvider] that uses AWS Login to source credentials. + * + * The provider does not initiate or perform the AWS Login flow. It is expected that you have + * already performed the login flow using the AWS CLI (`aws login`). The provider + * expects a valid non-expired access token for the AWS Login session in `~/.aws/login/cache` or + * the directory specified by the `AWS_LOGIN_CACHE_DIRECTORY` environment variable. + * If a cached token is not found, is expired, or the file is malformed an exception will be thrown. + * + * **Instantiating AWS Login provider directly** + * + * You can programmatically construct the AWS Login provider in your application, and provide the necessary + * information to load and retrieve temporary credentials using an access token from `~/.aws/login/cache` or + * the directory specified by the `AWS_LOGIN_CACHE_DIRECTORY` environment variable. + * + * ``` + * // Wrap the provider with a caching provider to cache the credentials until their expiration time + * val loginProvider = LoginCredentialsProvider( + * loginSession = "my-login-session" + * ).cached() + * ``` + * It is important that you wrap the provider with [CachedCredentialsProvider] if you are programmatically 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. + * + * @param loginSession The Login Session from the profile + * @param region The AWS region used to call the log in service. + * @param httpClient The [HttpClientEngine] instance to use to make requests. NOTE: This engine's resources and lifetime + * are NOT managed by the provider. Caller is responsible for closing. + * @param platformProvider The platform provider + * @param clock The source of time for the provider + */ +public class LoginCredentialsProvider public constructor( + public val loginSession: String, + public val region: String? = null, + public val httpClient: HttpClientEngine? = null, + public val platformProvider: PlatformProvider = PlatformProvider.System, + private val clock: Clock = Clock.System, +) : CloseableCredentialsProvider { + private val cacheDirectory = resolveCacheDir(platformProvider) + private val client = runBlocking { signinClient(region, httpClient) } + + override suspend fun resolve(attributes: Attributes): Credentials { + val logger = coroutineContext.logger() + + val loginTokenProvider = + LoginTokenProvider( + loginSession, + region, + httpClient = httpClient, + platformProvider = platformProvider, + clock = clock, + cacheDirectory = cacheDirectory, + client = client, + ) + + logger.trace { "Attempting to load token using token provider for login-session: `$loginSession`" } + val creds = loginTokenProvider.resolve(attributes) + + return creds.withBusinessMetric(AwsBusinessMetric.Credentials.CREDENTIALS_LOGIN) + } + + override fun close() { + client.close() + } +} + +internal fun resolveCacheDir(platformProvider: PlatformProvider) = + platformProvider.getenv("AWS_LOGIN_IN_CACHE_DIRECTORY") ?: platformProvider.filepath("~", ".aws", "login", "cache") + +internal suspend fun signinClient(providedRegion: String? = null, providedHttpClient: HttpClientEngine? = null) = + SigninClient.fromEnvironment { + region = providedRegion + httpClient = providedHttpClient + } diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/LoginTokenProvider.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/LoginTokenProvider.kt new file mode 100644 index 00000000000..7794002bcfa --- /dev/null +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/LoginTokenProvider.kt @@ -0,0 +1,452 @@ +/* + * 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.credentials +import aws.sdk.kotlin.runtime.auth.credentials.internal.signin.SigninClient +import aws.sdk.kotlin.runtime.auth.credentials.internal.signin.createOAuth2Token +import aws.sdk.kotlin.runtime.auth.credentials.internal.signin.model.AccessDeniedException +import aws.sdk.kotlin.runtime.auth.credentials.internal.signin.model.OAuth2ErrorCode +import aws.sdk.kotlin.runtime.auth.credentials.internal.signin.withConfig +import aws.sdk.kotlin.runtime.config.profile.normalizePath +import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials +import aws.smithy.kotlin.runtime.auth.awscredentials.CredentialsProvider +import aws.smithy.kotlin.runtime.client.ProtocolRequestInterceptorContext +import aws.smithy.kotlin.runtime.collections.Attributes +import aws.smithy.kotlin.runtime.hashing.ecdsaSecp256r1Rs +import aws.smithy.kotlin.runtime.hashing.sha256 +import aws.smithy.kotlin.runtime.http.engine.HttpClientEngine +import aws.smithy.kotlin.runtime.http.interceptors.HttpInterceptor +import aws.smithy.kotlin.runtime.http.request.HttpRequest +import aws.smithy.kotlin.runtime.http.request.header +import aws.smithy.kotlin.runtime.http.request.toBuilder +import aws.smithy.kotlin.runtime.io.use +import aws.smithy.kotlin.runtime.serde.json.JsonToken +import aws.smithy.kotlin.runtime.serde.json.jsonStreamReader +import aws.smithy.kotlin.runtime.serde.json.jsonStreamWriter +import aws.smithy.kotlin.runtime.serde.json.nextTokenOf +import aws.smithy.kotlin.runtime.telemetry.logging.debug +import aws.smithy.kotlin.runtime.telemetry.logging.error +import aws.smithy.kotlin.runtime.text.encoding.decodeBase64Bytes +import aws.smithy.kotlin.runtime.text.encoding.encodeToHex +import aws.smithy.kotlin.runtime.time.Clock +import aws.smithy.kotlin.runtime.time.Instant +import aws.smithy.kotlin.runtime.time.TimestampFormat +import aws.smithy.kotlin.runtime.util.PlatformProvider +import aws.smithy.kotlin.runtime.util.SingleFlightGroup +import aws.smithy.kotlin.runtime.util.Uuid +import kotlin.coroutines.coroutineContext +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.Base64.Default.UrlSafe +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +private const val DEFAULT_SIGNIN_TOKEN_REFRESH_BUFFER_SECONDS = 60 * 5 +private const val PROVIDER_NAME = "LOGIN" + +/** + * HTTP interceptor that adds DPoP (Demonstration of Proof-of-Possession) headers to requests. + */ +private class DpopInterceptor(private val dpopKeyPem: String) : HttpInterceptor { + override suspend fun modifyBeforeTransmit(context: ProtocolRequestInterceptorContext): HttpRequest { + val endpoint = context.protocolRequest.url.toString() + val dpopHeader = generateDpopProof(dpopKeyPem, endpoint) + + val request = context.protocolRequest.toBuilder() + + request.header("DPoP", dpopHeader) + return request.build() + } +} + +/** + * LoginTokenProvider provides a utility for refreshing AWS Login tokens for credential authentication. + * The provider can only be used to refresh already cached login tokens. This utility cannot + * perform the initial login flow. + * + * A utility such as the AWS CLI must be used to initially create the login session and cached token file before the + * application using the provider will need to retrieve the login token. If the token has not been cached already, + * this provider will return an error when attempting to retrieve the token. + * + * This provider will attempt to refresh the cached login token periodically if needed when [resolve] is + * called and a refresh token is available. + * + * @param loginSessionName the name of the login session from the shared config file to load tokens for + * @param region The AWS region used to call the log in service. + * @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 5 minutes. + * @param httpClient the [HttpClientEngine] instance to use to make requests. NOTE: This engine's resources and lifetime + * are NOT managed by the provider. Caller is responsible for closing. + * @param platformProvider the platform provider to use + * @param clock the source of time for the provider + */ +internal class LoginTokenProvider( + val loginSessionName: String, + val region: String? = null, + val refreshBufferWindow: Duration = DEFAULT_SIGNIN_TOKEN_REFRESH_BUFFER_SECONDS.seconds, + val httpClient: HttpClientEngine? = null, + val platformProvider: PlatformProvider = PlatformProvider.System, + val clock: Clock = Clock.System, + val cacheDirectory: String, + val client: SigninClient, +) : CredentialsProvider { + + // debounce concurrent requests for a token + private val sfg = SingleFlightGroup() + + override suspend fun resolve(attributes: Attributes): Credentials { + val token = sfg.singleFlight { getToken(attributes) } + + return credentials( + accessKeyId = token.accessKeyId, + secretAccessKey = token.secretAccessKey, + sessionToken = token.sessionToken, + expiration = token.expiresAt, + providerName = PROVIDER_NAME, + accountId = token.accountId, + ) + } + + private suspend fun getToken(attributes: Attributes): LoginToken { + val token = readLoginTokenFromCache(loginSessionName, platformProvider, cacheDirectory) + + if (clock.now() < (token.expiresAt - refreshBufferWindow)) { + coroutineContext.debug { "using cached token for login-session: $loginSessionName" } + return token + } + + return try { + attemptRefresh(token) + } catch (e: Exception) { + token.takeIf { clock.now() < it.expiresAt }?.also { + coroutineContext.debug { "cached token is not refreshable but still valid until ${it.expiresAt} for login-session: $loginSessionName" } + } ?: throwTokenExpired(e) + } + } + + private suspend fun attemptRefresh(oldToken: LoginToken): LoginToken { + coroutineContext.debug { "attempting to refresh token for login-session: $loginSessionName" } + val result = runCatching { refreshToken(oldToken) } + return result + .onSuccess { refreshed -> writeToken(refreshed) } + .getOrElse { cause -> + if (clock.now() >= oldToken.expiresAt) { + coroutineContext.error(cause) { "token refresh failed" } + throwTokenExpired(cause) + } + coroutineContext.debug { "refresh token failed, original token is still valid until ${oldToken.expiresAt} for login-session: $loginSessionName, re-using" } + oldToken + } + } + + private suspend fun writeToken(refreshed: LoginToken) { + val cacheKey = getLoginCacheFilename(loginSessionName) + val filepath = normalizePath(platformProvider.filepath(cacheDirectory, cacheKey), platformProvider) + val contents = serializeLoginToken(refreshed) + try { + platformProvider.writeFile(filepath, contents) + } catch (ex: Exception) { + coroutineContext.debug(ex) { "failed to write refreshed token back to disk at $filepath" } + throw ex + } + } + + private fun throwTokenExpired(cause: Throwable? = null, message: String? = null): Nothing = + throw InvalidLoginTokenException(message ?: "Login token for login-session: $loginSessionName is expired", cause) + + private suspend fun refreshToken(oldToken: LoginToken): LoginToken { + client.withConfig { + interceptors += DpopInterceptor(oldToken.dpopKey) + }.use { client -> + return try { + val result = client.createOAuth2Token { + tokenInput { + clientId = oldToken.clientId + grantType = "refresh_token" + refreshToken = oldToken.refreshToken + } + } + + oldToken.copy( + accessKeyId = result.tokenOutput!!.accessToken!!.accessKeyId, + secretAccessKey = result.tokenOutput.accessToken.secretAccessKey, + sessionToken = result.tokenOutput.accessToken.sessionToken, + expiresAt = clock.now() + result.tokenOutput.expiresIn.seconds, + refreshToken = result.tokenOutput.refreshToken, + ) + } catch (e: Exception) { + if (e is AccessDeniedException) { + when (e.error) { + is OAuth2ErrorCode.AuthcodeExpired -> { + throwTokenExpired( + e, + buildString { + append("Unable to complete the login process due to an expired authorization code. ") + append("Please reauthenticate using 'aws login'.") + }, + ) + } + is OAuth2ErrorCode.TokenExpired -> { + throwTokenExpired( + e, + "Your session has expired. Please reauthenticate.", + ) + } + is OAuth2ErrorCode.UserCredentialsChanged -> { + throwTokenExpired( + e, + buildString { + append("Unable to refresh credentials because of a change in your password. ") + append("Please reauthenticate with your new password.") + }, + ) + } + is OAuth2ErrorCode.InsufficientPermissions -> { + throwTokenExpired( + e, + buildString { + append("Unable to refresh credentials due to insufficient permissions. ") + append("You may be missing permission for the 'CreateOAuth2Token' action.") + }, + ) + } + else -> throw e + } + } else { + throw e + } + } + } + } +} + +internal data class ECKeyData( + val d: ByteArray, // private key scalar + val x: ByteArray, // public key x coordinate + val y: ByteArray, // public key y coordinate +) + +/** + * Parses a PEM-encoded EC private key and extracts the private key scalar and public key (x, y) coordinates. + * Supports "EC PRIVATE KEY" PEM formats for P-256 curve keys. + */ +private fun parseECKeyPem(pem: String): ECKeyData { + val base64 = pem.replace("-----BEGIN EC PRIVATE KEY-----", "") + .replace("-----END EC PRIVATE KEY-----", "") + .replace("\\s".toRegex(), "") + .replace("\n", "") + .replace("\r", "") + + val der = base64.decodeBase64Bytes() + + // Extract private key scalar (32 bytes at offset 7) + val d = der.copyOfRange(7, 39) + + // Find public key coordinates (look for 0x04 prefix after offset 40) + var publicKeyStart = -1 + for (i in 40 until der.size) { + if (der[i] == 0x04.toByte()) { + publicKeyStart = i + 1 + break + } + } + + val remainingBytes = der.size - publicKeyStart + val coordLen = remainingBytes / 2 + + val x = der.copyOfRange(publicKeyStart, publicKeyStart + coordLen).padTo32() + val y = der.copyOfRange(publicKeyStart + coordLen, publicKeyStart + 2 * coordLen).padTo32() + + return ECKeyData(d, x, y) +} + +private fun ByteArray.padTo32(): ByteArray = + if (size > 32) { + error("Unexpected byte array of size $size; expected 32 bytes or less") + } else { + ByteArray(32 - size) + this + } + +/** + * Generates a DPoP (Demonstration of Proof-of-Possession) JWT proof for OAuth 2.0 requests. + * Creates a signed JWT with the required claims (jti, htm, htu, iat) using ES256 algorithm. + */ +private fun generateDpopProof( + privateKeyPem: String, + endpoint: String, +): String { + val ecKeyData = parseECKeyPem(privateKeyPem) + + val base64UrlNoPadding = UrlSafe.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL) + + val header = jsonStreamWriter().apply { + beginObject() + writeName("typ") + writeValue("dpop+jwt") + writeName("alg") + writeValue("ES256") + writeName("jwk") + beginObject() + writeName("kty") + writeValue("EC") + writeName("x") + writeValue(base64UrlNoPadding.encode(ecKeyData.x)) + writeName("y") + writeValue(base64UrlNoPadding.encode(ecKeyData.y)) + writeName("crv") + writeValue("P-256") + endObject() + endObject() + }.bytes + + val payload = jsonStreamWriter().apply { + beginObject() + writeName("jti") + writeValue(Uuid.random().toString()) + writeName("htm") + writeValue("POST") + writeName("htu") + writeValue(endpoint) + writeName("iat") + writeValue(Clock.System.now().epochSeconds) + endObject() + }.bytes + + val headerEncoded = base64UrlNoPadding.encode(header!!) + val payloadEncoded = base64UrlNoPadding.encode(payload!!) + val message = "$headerEncoded.$payloadEncoded" + + val privateKeyBytes = ecKeyData.d + val signature = ecdsaSecp256r1Rs(privateKeyBytes, message.encodeToByteArray()) + + return "$message.${ base64UrlNoPadding.encode(signature) }" +} + +internal suspend fun readLoginTokenFromCache(cacheKey: String, platformProvider: PlatformProvider, cacheDirectory: String): LoginToken { + val key = getLoginCacheFilename(cacheKey) + val bytes = with(platformProvider) { + val defaultCacheLocation = normalizePath(cacheDirectory, this) + readFileOrNull(filepath(defaultCacheLocation, key)) + } ?: throw ProviderConfigurationException("Invalid or missing login session cache. Run `aws login` to initiate a new session") + return deserializeLoginToken(bytes) +} + +internal fun getLoginCacheFilename(cacheKey: String): String { + val sha256HexDigest = cacheKey.trim().encodeToByteArray().sha256().encodeToHex() + return "$sha256HexDigest.json" +} + +internal data class LoginToken( + val accessKeyId: String, + val secretAccessKey: String, + val sessionToken: String, + val accountId: String, + val tokenType: String? = null, + val expiresAt: Instant, + val refreshToken: String, + val idToken: String? = null, + val clientId: String, + val dpopKey: String, +) + +internal fun deserializeLoginToken(json: ByteArray): LoginToken { + val lexer = jsonStreamReader(json) + + var sessionToken: String? = null + var accessKeyId: String? = null + var secretAccessKey: String? = null + var accountId: String? = null + var tokenType: String? = null + var expiresAtRfc3339: String? = null + var refreshToken: String? = null + var idToken: String? = null + var clientId: String? = null + var dpopKey: String? = null + var hasAccessToken = false + + try { + lexer.nextTokenOf() + loop@while (true) { + when (val token = lexer.nextToken()) { + is JsonToken.EndObject -> break@loop + is JsonToken.Name -> when (token.value) { + "accessToken" -> { + hasAccessToken = true + lexer.nextTokenOf() + while (true) { + when (val nestedToken = lexer.nextToken()) { + is JsonToken.EndObject -> break + is JsonToken.Name -> when (nestedToken.value) { + "accessKeyId" -> accessKeyId = lexer.nextTokenOf().value + "secretAccessKey" -> secretAccessKey = lexer.nextTokenOf().value + "sessionToken" -> sessionToken = lexer.nextTokenOf().value + "expiresAt" -> expiresAtRfc3339 = lexer.nextTokenOf().value + "accountId" -> accountId = lexer.nextTokenOf().value + else -> lexer.skipNext() + } + else -> error("expected key or end of object in accessToken") + } + } + } + "tokenType" -> tokenType = lexer.nextTokenOf().value + "refreshToken" -> refreshToken = lexer.nextTokenOf().value + "idToken" -> idToken = lexer.nextTokenOf().value + "clientId" -> clientId = lexer.nextTokenOf().value + "dpopKey" -> dpopKey = lexer.nextTokenOf().value + else -> lexer.skipNext() + } + else -> error("expected either key or end of object") + } + } + } catch (ex: Exception) { + throw InvalidLoginTokenException("invalid cached login token", ex) + } + if (!hasAccessToken) throw InvalidLoginTokenException("missing `accessToken`") + if (accessKeyId == null) throw InvalidLoginTokenException("missing `accessKeyId`") + if (secretAccessKey == null) throw InvalidLoginTokenException("missing `secretAccessKey`") + if (sessionToken == null) throw InvalidLoginTokenException("missing `sessionToken`") + if (accountId == null) throw InvalidLoginTokenException("missing `accountId`") + val expiresAt = expiresAtRfc3339?.let { Instant.fromIso8601(it) } ?: throw InvalidLoginTokenException("missing `expiresAt`") + if (clientId == null) throw InvalidLoginTokenException("missing `clientId`") + if (refreshToken == null) throw InvalidLoginTokenException("missing `refreshToken`") + if (dpopKey == null) throw InvalidLoginTokenException("missing `dpopKey`") + + return LoginToken( + accessKeyId, + secretAccessKey, + sessionToken, + accountId, + tokenType, + expiresAt, + refreshToken, + idToken, + clientId, + dpopKey, + ) +} + +internal fun serializeLoginToken(token: LoginToken): ByteArray = + jsonStreamWriter(pretty = true).apply { + beginObject() + writeName("accessToken") + beginObject() + writeNotNull("accessKeyId", token.accessKeyId) + writeNotNull("secretAccessKey", token.secretAccessKey) + writeNotNull("sessionToken", token.sessionToken) + writeNotNull("accountId", token.accountId) + writeNotNull("expiresAt", token.expiresAt.format(TimestampFormat.ISO_8601)) + endObject() + writeNotNull("tokenType", token.tokenType) + writeNotNull("refreshToken", token.refreshToken) + writeNotNull("idToken", token.idToken) + writeNotNull("clientId", token.clientId) + writeNotNull("dpopKey", token.dpopKey) + endObject() + }.bytes ?: error("serializing LoginToken failed") + +public class InvalidLoginTokenException(message: String, cause: Throwable? = null) : ConfigurationException(message, cause) diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/ProfileCredentialsProvider.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/ProfileCredentialsProvider.kt index de987a6905a..d19aeba3a92 100644 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/ProfileCredentialsProvider.kt +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/ProfileCredentialsProvider.kt @@ -200,6 +200,10 @@ public class ProfileCredentialsProvider @InternalSdkApi constructor( credentialsBusinessMetrics.add(AwsBusinessMetric.Credentials.CREDENTIALS_PROFILE_SSO_LEGACY) } + is LeafProvider.LoginSession -> LoginCredentialsProvider(loginSessionName, region.get()).also { + credentialsBusinessMetrics.add(AwsBusinessMetric.Credentials.CREDENTIALS_PROFILE_LOGIN) + } + is LeafProvider.Process -> ProcessCredentialsProvider(command).also { credentialsBusinessMetrics.add(AwsBusinessMetric.Credentials.CREDENTIALS_PROFILE_PROCESS) } @@ -223,6 +227,7 @@ public class ProfileCredentialsProvider @InternalSdkApi constructor( is LeafProvider.WebIdentityTokenRole -> "web identity token" is LeafProvider.SsoSession -> "single sign-on (session)" is LeafProvider.LegacySso -> "single sign-on (legacy)" + is LeafProvider.LoginSession -> "aws login" is LeafProvider.Process -> "process" } diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/SsoTokenProvider.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/SsoTokenProvider.kt index 684d37c901c..58f781548b8 100644 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/SsoTokenProvider.kt +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/SsoTokenProvider.kt @@ -244,7 +244,7 @@ internal fun serializeSsoToken(token: SsoToken): ByteArray = endObject() }.bytes ?: error("serializing SsoToken failed") -private fun JsonStreamWriter.writeNotNull(name: String, value: String?) { +internal fun JsonStreamWriter.writeNotNull(name: String, value: String?) { if (value == null) return writeName(name) writeValue(value) diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/profile/LeafProvider.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/profile/LeafProvider.kt index 5163f80cc8a..db6a8e306f1 100644 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/profile/LeafProvider.kt +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/profile/LeafProvider.kt @@ -91,6 +91,19 @@ internal sealed class LeafProvider { val ssoRoleName: String, ) : LeafProvider() + /** + * A provider that uses AWS login + * + * Example + * ```ini + * [profile W] + * login_session = arn:aws:iam::0123456789012:user/Admin + * ``` + */ + data class LoginSession( + val loginSessionName: String, + ) : LeafProvider() + /** * A provider that invokes a command and reads its standard output to parse credentials. */ diff --git a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/profile/ProfileChain.kt b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/profile/ProfileChain.kt index b43f803341e..93cf15722ba 100644 --- a/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/profile/ProfileChain.kt +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/profile/ProfileChain.kt @@ -151,6 +151,8 @@ internal const val SSO_ACCOUNT_ID = "sso_account_id" internal const val SSO_ROLE_NAME = "sso_role_name" internal const val SSO_SESSION = "sso_session" +internal const val LOGIN_SESSION = "login_session" + internal const val CREDENTIAL_PROCESS = "credential_process" private fun AwsProfile.roleArnOrNull(): RoleArn? { @@ -268,6 +270,15 @@ private fun AwsProfile.ssoSessionCreds(config: AwsSharedConfig): LeafProviderRes return LeafProviderResult.Ok(LeafProvider.SsoSession(sessionName, startUrl, ssoRegion, accountId, roleName)) } +/** + * Attempt to load [LeafProvider.LoginSession] from the current profile or `null` if the profile + * does not contain a login session configuration. + */ +private fun AwsProfile.loginSessionCreds(): LeafProviderResult? { + val sessionName = getOrNull(LOGIN_SESSION) ?: return null + return LeafProviderResult.Ok(LeafProvider.LoginSession(sessionName)) +} + /** * Attempt to load [LeafProvider.Process] from the current profile or exception if the current profile does not contain * a credentials process command to execute @@ -348,6 +359,7 @@ private fun AwsProfile.leafProvider(config: AwsSharedConfig): LeafProvider { return webIdentityTokenCreds() .orElse { ssoSessionCreds(config) } .orElse(::legacySsoCreds) + .orElse(::loginSessionCreds) .unwrapOrElse(::processCreds) .unwrap() } diff --git a/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/LoginCredentialsProviderTest.kt b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/LoginCredentialsProviderTest.kt new file mode 100644 index 00000000000..d8d0744612c --- /dev/null +++ b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/LoginCredentialsProviderTest.kt @@ -0,0 +1,149 @@ +/* + * 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.auth.credentials.internal.credentials +import aws.sdk.kotlin.runtime.http.interceptors.businessmetrics.AwsBusinessMetric +import aws.sdk.kotlin.runtime.http.interceptors.businessmetrics.withBusinessMetric +import aws.smithy.kotlin.runtime.http.Headers +import aws.smithy.kotlin.runtime.http.HttpBody +import aws.smithy.kotlin.runtime.http.HttpStatusCode +import aws.smithy.kotlin.runtime.http.response.HttpResponse +import aws.smithy.kotlin.runtime.httptest.TestConnection +import aws.smithy.kotlin.runtime.httptest.buildTestConnection +import aws.smithy.kotlin.runtime.time.Instant +import aws.smithy.kotlin.runtime.time.ManualClock +import aws.smithy.kotlin.runtime.util.TestPlatformProvider +import io.kotest.matchers.string.shouldMatch +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.text.encodeToByteArray +import kotlin.to + +class LoginCredentialsProviderTest { + + @Test + fun testCacheFilename() { + val expected = "36db1d138ff460920374e4c3d8e01f53f9f73537e89c88d639f68393df0e2726.json" + val actual = getLoginCacheFilename("arn:aws:iam::0123456789012:user/Admin") + assertEquals(expected, actual) + } + + @Test + fun testExpiredToken() = runTest { + val engine = TestConnection() + + val epoch = "2025-09-15T04:05:45Z" + val testClock = ManualClock(epoch = Instant.fromIso8601(epoch)) + + val contents = """ + { + "accessToken": { + "accessKeyId": "AKIAIOSFODNN7EXAMPLE", + "secretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "sessionToken": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKwRcOIfrRh3c/LTo6UDdyJwOOvEVPvLXCrrrUtdnniCEXAMPLE/IvU1dYUg2RVAJBanLiHb4IgRmpRV3zrkuWJOgQs8IZZaIv2BXIa2R4OlgkBN9bkUDNCJiBeb/AXlzBBko7b15fjrBs2+cTQtpZ3CYWFXG8C5zqx37wnOE49mRl/+OtkIKGO7fAE", + "accountId": "012345678901", + "expiresAt": "2025-09-14T04:05:45Z" + }, + "tokenType": "aws_sigv4", + "refreshToken": "", + "identityToken": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWV9.EkN-DOsnsuRjRO6BxXemmJDm3HbxrbRzXglbN2S4sOkopdU4IsDxTI8jO19W_A4K8ZPJijNLis4EZsHeY559a4DFOd50_OqgHs3UjpbCqhpuU5K_TGOj3pY-TJXSw", + "clientId": "arn:aws:signin:::devtools/same-device", + "dpopKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIFDZHUzOG1Pzq+6F0mjMlOSp1syN9LRPBuHMoCFXTcXhoAoGCCqGSM49\nAwEHoUQDQgAE9qhj+KtcdHj1kVgwxWWWw++tqoh7H7UHs7oXh8jBbgF47rrYGC+t\ndjiIaHK3dBvvdE7MGj5HsepzLm3Kj91bqA==\n-----END EC PRIVATE KEY-----\n" + } + """ + + val key = getLoginCacheFilename("arn:aws:iam::0123456789012:user/Admin") + + val testPlatform = TestPlatformProvider( + env = mapOf("HOME" to "/home"), + fs = mapOf("/home/.aws/login/cache/$key" to contents), + ) + + val provider = LoginCredentialsProvider( + loginSession = "arn:aws:iam::0123456789012:user/Admin", + httpClient = engine, + platformProvider = testPlatform, + clock = testClock, + ) + + assertFailsWith { + provider.resolve() + }.message.shouldMatch(Regex("Login token for login-session: .* is expired")) + } + + @Test + fun testSuccess() = runTest { + val expectedExpiration = Instant.fromIso8601("2020-10-16T04:56:00Z") + + val serviceResp = """ + { + "accessToken": { + "accessKeyId": "AKID", + "secretAccessKey": "secret", + "sessionToken": "session-token" + }, + "expiresIn": 3600, + "refreshToken": "new-refresh-token", + "tokenType": "aws_sigv4" + } + """ + + val engine = buildTestConnection { + expect( + HttpResponse(HttpStatusCode.OK, Headers.Empty, HttpBody.fromBytes(serviceResp.encodeToByteArray())), + ) + } + + val epoch = "2020-10-16T03:56:00Z" + val testClock = ManualClock(epoch = Instant.fromIso8601(epoch)) + + val contents = """ + { + "accessToken": { + "accessKeyId": "OLD_AKID", + "secretAccessKey": "old-secret", + "sessionToken": "old-session-token", + "accountId": "123456789", + "expiresAt": "2020-10-16T03:50:00Z" + }, + "tokenType": "aws_sigv4", + "refreshToken": "refresh-token", + "clientId": "test-client-id", + "dpopKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIFDZHUzOG1Pzq+6F0mjMlOSp1syN9LRPBuHMoCFXTcXhoAoGCCqGSM49\nAwEHoUQDQgAE9qhj+KtcdHj1kVgwxWWWw++tqoh7H7UHs7oXh8jBbgF47rrYGC+t\ndjiIaHK3dBvvdE7MGj5HsepzLm3Kj91bqA==\n-----END EC PRIVATE KEY-----\n" + } + """ + + val key = getLoginCacheFilename("arn:aws:iam::123456789:user/TestUser") + + val testPlatform = TestPlatformProvider( + env = mapOf("HOME" to "/home"), + fs = mapOf("/home/.aws/login/cache/$key" to contents), + ) + + val provider = LoginCredentialsProvider( + loginSession = "arn:aws:iam::123456789:user/TestUser", + region = "us-west-2", + httpClient = engine, + platformProvider = testPlatform, + clock = testClock, + ) + + val actual = provider.resolve() + val expected = credentials( + accessKeyId = "AKID", + secretAccessKey = "secret", + sessionToken = "session-token", + expiration = expectedExpiration, + providerName = "LOGIN", + accountId = "123456789", + ).withBusinessMetric(AwsBusinessMetric.Credentials.CREDENTIALS_LOGIN) + + assertEquals(expected, actual) + } +} diff --git a/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/LoginTokenProviderTest.kt b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/LoginTokenProviderTest.kt new file mode 100644 index 00000000000..9a175d4b276 --- /dev/null +++ b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/LoginTokenProviderTest.kt @@ -0,0 +1,450 @@ +/* + * 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.client.AwsClientOption +import aws.smithy.kotlin.runtime.http.Headers +import aws.smithy.kotlin.runtime.http.HttpBody +import aws.smithy.kotlin.runtime.http.HttpStatusCode +import aws.smithy.kotlin.runtime.http.response.HttpResponse +import aws.smithy.kotlin.runtime.httptest.TestConnection +import aws.smithy.kotlin.runtime.httptest.buildTestConnection +import aws.smithy.kotlin.runtime.time.Instant +import aws.smithy.kotlin.runtime.time.ManualClock +import aws.smithy.kotlin.runtime.util.TestPlatformProvider +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.* +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.also +import kotlin.collections.filterKeys +import kotlin.collections.forEach +import kotlin.collections.forEachIndexed +import kotlin.collections.map +import kotlin.collections.mapValues +import kotlin.collections.set +import kotlin.getOrThrow +import kotlin.runCatching +import kotlin.test.* +import kotlin.text.decodeToString +import kotlin.text.encodeToByteArray +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.to +import kotlin.toString + +class LoginTokenProviderTest { + private data class LoginTestCase( + val name: String, + val configContents: String, + val cacheContents: Map, + val mockApiCalls: JsonArray?, + val outcomes: List, + ) { + companion object { + fun fromJson(json: JsonObject): LoginTestCase { + val name = json["documentation"]!!.jsonPrimitive.content + val configContents = json["configContents"]!!.jsonPrimitive.content + val cacheContents = json["cacheContents"]!!.jsonObject.mapValues { (_, value) -> + value.toString() + } + val mockApiCalls = json["mockApiCalls"]?.jsonArray + val outcomes = json["outcomes"]!!.jsonArray.map { outcome -> + val outcomeObj = outcome.jsonObject + val result = outcomeObj["result"]!!.jsonPrimitive.content + when (result) { + "credentials" -> TestOutcome.Credentials( + accessKeyId = outcomeObj["accessKeyId"]!!.jsonPrimitive.content, + secretAccessKey = outcomeObj["secretAccessKey"]!!.jsonPrimitive.content, + sessionToken = outcomeObj["sessionToken"]!!.jsonPrimitive.content, + accountId = outcomeObj["accountId"]!!.jsonPrimitive.content, + expiresAt = Instant.fromIso8601(outcomeObj["expiresAt"]!!.jsonPrimitive.content), + ) + "cacheContents" -> TestOutcome.CacheContents( + cacheContents = outcomeObj.filterKeys { it != "result" }.mapValues { it.value.toString() }, + ) + "error" -> TestOutcome.Error( + message = outcomeObj["message"]!!.jsonPrimitive.content, + ) + else -> error("Unknown result type: $result") + } + } + return LoginTestCase(name, configContents, cacheContents, mockApiCalls, outcomes) + } + } + } + + private sealed class TestOutcome { + data class Credentials( + val accessKeyId: String, + val secretAccessKey: String, + val sessionToken: String, + val accountId: String, + val expiresAt: Instant, + ) : TestOutcome() + + data class CacheContents( + val cacheContents: Map, + ) : TestOutcome() + + data class Error(val message: String) : TestOutcome() + } + + @Test + fun testLoginTokenCacheBehavior() = runTest(timeout = 2.minutes) { + val testList = Json.parseToJsonElement(LOGIN_TOKEN_PROVIDER_TEST_SUITE).jsonArray + testList.map { testCase -> + runCatching { + LoginTestCase.fromJson(testCase.jsonObject) + }.also { + if (it.isFailure) { + fail("failed to parse test case: `$testCase`", it.exceptionOrNull()) + } + }.getOrThrow() + }.forEachIndexed { idx, testCase -> + val loginSessionName = "arn:aws:sts::012345678910:assumed-role/Admin/admin" + + // Setup filesystem with cache files + val fs = mutableMapOf() + testCase.cacheContents.forEach { (filename, content) -> + fs["/home/.aws/login/cache/$filename"] = content + } + + val testPlatform = TestPlatformProvider( + env = mapOf("HOME" to "/home"), + fs = fs, + ) + + val testClock = ManualClock(Instant.fromIso8601("2025-11-19T00:00:00Z")) + + val httpClient = if (testCase.mockApiCalls != null) { + buildTestConnection { + testCase.mockApiCalls.forEach { mockCall -> + val responseCode = mockCall.jsonObject["responseCode"]?.jsonPrimitive?.int ?: 200 + val statusCode = HttpStatusCode.fromValue(responseCode) + if (responseCode == 200) { + val response = mockCall.jsonObject["response"]?.jsonObject["tokenOutput"]?.jsonObject + val body = response.toString().encodeToByteArray() + expect( + HttpResponse( + statusCode, + Headers.Empty, + HttpBody.fromBytes(body), + ), + ) + } else { + expect(HttpResponse(statusCode, Headers.Empty, HttpBody.Empty)) + } + } + } + } else { + TestConnection() + } + + val tokenProvider = LoginTokenProvider( + loginSessionName = loginSessionName, + region = "us-west-2", + refreshBufferWindow = 0.seconds, + httpClient = httpClient, + platformProvider = testPlatform, + clock = testClock, + cacheDirectory = resolveCacheDir(testPlatform), + client = signinClient("us-west-2", providedHttpClient = httpClient), + ) + + testCase.outcomes.forEach { expectedOutcome -> + when (expectedOutcome) { + is TestOutcome.Credentials -> { + // Verify that credentials are successfully resolved and match expected values + val credentials = tokenProvider.resolve() + assertEquals(expectedOutcome.accessKeyId, credentials.accessKeyId, "[idx=$idx]: $testCase") + assertEquals( + expectedOutcome.secretAccessKey, + credentials.secretAccessKey, + "[idx=$idx]: $testCase", + ) + assertEquals(expectedOutcome.sessionToken, credentials.sessionToken, "[idx=$idx]: $testCase") + assertEquals( + expectedOutcome.accountId, + credentials.attributes.getOrNull(AwsClientOption.AccountId), + "[idx=$idx]: $testCase", + ) + assertEquals(expectedOutcome.expiresAt, credentials.expiration, "[idx=$idx]: $testCase") + } + + is TestOutcome.CacheContents -> { + // Verify cache contents after token refresh + expectedOutcome.cacheContents.forEach { (filename, expectedContent) -> + val actualContent = + testPlatform.readFileOrNull("/home/.aws/login/cache/$filename")?.decodeToString() + assertNotNull(actualContent, "Cache file $filename should exist") + + val expectedJson = Json.parseToJsonElement(expectedContent).jsonObject + val actualJson = Json.parseToJsonElement(actualContent).jsonObject + assertEquals(expectedJson, actualJson, "Cache content mismatch for $filename") + } + } + + is TestOutcome.Error -> { + val exception = assertFails("[idx=$idx]: $testCase") { + tokenProvider.resolve() + } + assertEquals( + exception.message?.contains(expectedOutcome.message), + true, + "[idx=$idx]: Expected error message to contain '${expectedOutcome.message}', but got: ${exception.message}", + ) + } + } + } + } + } +} + +// Note to implementer: these test cases are copied from SEP: https://code.amazon.com/packages/AwsDrSeps/blobs/aadc5f3e3212c3b0a29a2ab6b1ce8dc548f7cfff/--/seps/accepted/shared/login/login-provider-test-cases.json +// Error messages in 'outcomes' (e.g., "missing `accessToken`") are manually added. Update these when updating test cases. +// language=JSON +private const val LOGIN_TOKEN_PROVIDER_TEST_SUITE = """ +[ + { + "documentation": "Success - Valid credentials are returned immediately", + "configContents": "[profile signin]\nlogin_session = arn:aws:sts::012345678910:assumed-role/Admin/admin\n", + "cacheContents": { + "4b0ba8f99f075c0633e122fd73346ce203a3faf18ea0310eb2d29df1bab2e255.json": { + "accessToken": { + "accessKeyId": "AKIAIOSFODNN7EXAMPLE", + "secretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "sessionToken": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKwRcOIfrRh3c/LTo6UDdyJwOOvEVPvLXCrrrUtdnniCEXAMPLE/IvU1dYUg2RVAJBanLiHb4IgRmpRV3zrkuWJOgQs8IZZaIv2BXIa2R4OlgkBN9bkUDNCJiBeb/AXlzBBko7b15fjrBs2+cTQtpZ3CYWFXG8C5zqx37wnOE49mRl/+OtkIKGO7fAE", + "accountId": "012345678901", + "expiresAt": "3025-09-14T04:05:45Z" + }, + "clientId": "arn:aws:signin:::devtools/same-device", + "refreshToken": "refresh_token", + "idToken": "eyJraWQiOiI1MzYxMjY2ZS1mNjI5LTQ0ZGQtOTA1My1jYzJkNTM1OTJiOTIiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiJhcm46YXdzOnN0czo6NzIxNzgxNjAzNzU1OmFzc3VtZWQtcm9sZVwvQWRtaW5cL3Nob3ZsaWEtSXNlbmdhcmQiLCJhdWQiOiJhcm46YXdzOnNpZ25pbjo6OmNsaVwvc2FtZS1kZXZpY2UiLCJpc3MiOiJodHRwczpcL1wvc2lnbmluLmF3cy5hbWF6b24uY29tXC9zaWduaW4iLCJzZXNzaW9uX2FybiI6ImFybjphd3M6c3RzOjo3MjE3ODE2MDM3NTU6YXNzdW1lZC1yb2xlXC9BZG1pblwvc2hvdmxpYS1Jc2VuZ2FyZCIsImV4cCI6MTc2MTE2Nzk0NiwiaWF0IjoxNzYxMTY3MDQ2fQ.EzySTg0K11hwQtIYtcBcnNMmX33F6XrVqXsk8WyTWjYcMQxaMnqXebLwBQBCRZha05hZiIZ5xPVCBIt7hZGyymurSfOL72cz69xHUH6u7rwu8vn10UKLHfyKLneKBlmJ", + "dpopKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIPt/u8InPLpQeQLJTvVX+sNDzni8vMDMt3Liu+nMBigfoAoGCCqGSM49\nAwEHoUQDQgAEILkGG7rNOnxiIJlMgimY1UPP8eDMFP0DAY6WGjngP4bvTAiUCQ/I\nffut2379uP+OBCm2ovGpBOJRgrl1RspUOQ==\n-----END EC PRIVATE KEY-----\n" + } + }, + "outcomes": [ + { + "result": "credentials", + "accessKeyId": "AKIAIOSFODNN7EXAMPLE", + "secretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "sessionToken": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKwRcOIfrRh3c/LTo6UDdyJwOOvEVPvLXCrrrUtdnniCEXAMPLE/IvU1dYUg2RVAJBanLiHb4IgRmpRV3zrkuWJOgQs8IZZaIv2BXIa2R4OlgkBN9bkUDNCJiBeb/AXlzBBko7b15fjrBs2+cTQtpZ3CYWFXG8C5zqx37wnOE49mRl/+OtkIKGO7fAE", + "accountId": "012345678901", + "expiresAt": "3025-09-14T04:05:45Z" + } + ] + }, + { + "documentation": "Failure - No cache file", + "configContents": "[profile signin]\nlogin_session = arn:aws:sts::012345678910:assumed-role/Admin/admin\n", + "cacheContents": { + }, + "outcomes": [ + { + "result": "error", + "message": "Invalid or missing login session cache. Run `aws login` to initiate a new session" + } + ] + }, + { + "documentation": "Failure - Missing accessToken", + "configContents": "[profile signin]\nlogin_session = arn:aws:sts::012345678910:assumed-role/Admin/admin\n", + "cacheContents": { + "4b0ba8f99f075c0633e122fd73346ce203a3faf18ea0310eb2d29df1bab2e255.json": { + "clientId": "arn:aws:signin:::devtools/same-device", + "refreshToken": "valid_refresh_token_456", + "idToken": "eyJraWQiOiI1MzYxMjY2ZS1mNjI5LTQ0ZGQtOTA1My1jYzJkNTM1OTJiOTIiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiJhcm46YXdzOnN0czo6NzIxNzgxNjAzNzU1OmFzc3VtZWQtcm9sZVwvQWRtaW5cL3Nob3ZsaWEtSXNlbmdhcmQiLCJhdWQiOiJhcm46YXdzOnNpZ25pbjo6OmNsaVwvc2FtZS1kZXZpY2UiLCJpc3MiOiJodHRwczpcL1wvc2lnbmluLmF3cy5hbWF6b24uY29tXC9zaWduaW4iLCJzZXNzaW9uX2FybiI6ImFybjphd3M6c3RzOjo3MjE3ODE2MDM3NTU6YXNzdW1lZC1yb2xlXC9BZG1pblwvc2hvdmxpYS1Jc2VuZ2FyZCIsImV4cCI6MTc2MTE2Nzk0NiwiaWF0IjoxNzYxMTY3MDQ2fQ.EzySTg0K11hwQtIYtcBcnNMmX33F6XrVqXsk8WyTWjYcMQxaMnqXebLwBQBCRZha05hZiIZ5xPVCBIt7hZGyymurSfOL72cz69xHUH6u7rwu8vn10UKLHfyKLneKBlmJ", + "dpopKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIPt/u8InPLpQeQLJTvVX+sNDzni8vMDMt3Liu+nMBigfoAoGCCqGSM49\nAwEHoUQDQgAEILkGG7rNOnxiIJlMgimY1UPP8eDMFP0DAY6WGjngP4bvTAiUCQ/I\nffut2379uP+OBCm2ovGpBOJRgrl1RspUOQ==\n-----END EC PRIVATE KEY-----\n" + } + }, + "outcomes": [ + { + "result": "error", + "message": "missing `accessToken`" + } + ] + }, + { + "documentation": "Failure - Missing refreshToken", + "configContents": "[profile signin]\nlogin_session = arn:aws:sts::012345678910:assumed-role/Admin/admin\n", + "cacheContents": { + "4b0ba8f99f075c0633e122fd73346ce203a3faf18ea0310eb2d29df1bab2e255.json": { + "accessToken": { + "accessKeyId": "AKIAIOSFODNN7EXAMPLE", + "secretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "sessionToken": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKwRcOIfrRh3c/LTo6UDdyJwOOvEVPvLXCrrrUtdnniCEXAMPLE/IvU1dYUg2RVAJBanLiHb4IgRmpRV3zrkuWJOgQs8IZZaIv2BXIa2R4OlgkBN9bkUDNCJiBeb/AXlzBBko7b15fjrBs2+cTQtpZ3CYWFXG8C5zqx37wnOE49mRl/+OtkIKGO7fAE", + "accountId": "012345678901", + "expiresAt": "2020-01-01T00:00:00Z" + }, + "clientId": "arn:aws:signin:::devtools/same-device", + "idToken": "eyJraWQiOiI1MzYxMjY2ZS1mNjI5LTQ0ZGQtOTA1My1jYzJkNTM1OTJiOTIiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiJhcm46YXdzOnN0czo6NzIxNzgxNjAzNzU1OmFzc3VtZWQtcm9sZVwvQWRtaW5cL3Nob3ZsaWEtSXNlbmdhcmQiLCJhdWQiOiJhcm46YXdzOnNpZ25pbjo6OmNsaVwvc2FtZS1kZXZpY2UiLCJpc3MiOiJodHRwczpcL1wvc2lnbmluLmF3cy5hbWF6b24uY29tXC9zaWduaW4iLCJzZXNzaW9uX2FybiI6ImFybjphd3M6c3RzOjo3MjE3ODE2MDM3NTU6YXNzdW1lZC1yb2xlXC9BZG1pblwvc2hvdmxpYS1Jc2VuZ2FyZCIsImV4cCI6MTc2MTE2Nzk0NiwiaWF0IjoxNzYxMTY3MDQ2fQ.EzySTg0K11hwQtIYtcBcnNMmX33F6XrVqXsk8WyTWjYcMQxaMnqXebLwBQBCRZha05hZiIZ5xPVCBIt7hZGyymurSfOL72cz69xHUH6u7rwu8vn10UKLHfyKLneKBlmJ", + "dpopKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIPt/u8InPLpQeQLJTvVX+sNDzni8vMDMt3Liu+nMBigfoAoGCCqGSM49\nAwEHoUQDQgAEILkGG7rNOnxiIJlMgimY1UPP8eDMFP0DAY6WGjngP4bvTAiUCQ/I\nffut2379uP+OBCm2ovGpBOJRgrl1RspUOQ==\n-----END EC PRIVATE KEY-----\n" + } + }, + "outcomes": [ + { + "result": "error", + "message": "missing `refreshToken`" + } + ] + }, + { + "documentation": "Failure - Missing clientId in cache", + "configContents": "[profile signin]\nlogin_session = arn:aws:sts::012345678910:assumed-role/Admin/admin\n", + "cacheContents": { + "4b0ba8f99f075c0633e122fd73346ce203a3faf18ea0310eb2d29df1bab2e255.json": { + "accessToken": { + "accessKeyId": "AKIAIOSFODNN7EXAMPLE", + "secretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "sessionToken": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKwRcOIfrRh3c/LTo6UDdyJwOOvEVPvLXCrrrUtdnniCEXAMPLE/IvU1dYUg2RVAJBanLiHb4IgRmpRV3zrkuWJOgQs8IZZaIv2BXIa2R4OlgkBN9bkUDNCJiBeb/AXlzBBko7b15fjrBs2+cTQtpZ3CYWFXG8C5zqx37wnOE49mRl/+OtkIKGO7fAE", + "accountId": "012345678901", + "expiresAt": "2020-01-01T00:00:00Z" + }, + "refreshToken": "valid_refresh_token_789", + "idToken": "eyJraWQiOiI1MzYxMjY2ZS1mNjI5LTQ0ZGQtOTA1My1jYzJkNTM1OTJiOTIiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiJhcm46YXdzOnN0czo6NzIxNzgxNjAzNzU1OmFzc3VtZWQtcm9sZVwvQWRtaW5cL3Nob3ZsaWEtSXNlbmdhcmQiLCJhdWQiOiJhcm46YXdzOnNpZ25pbjo6OmNsaVwvc2FtZS1kZXZpY2UiLCJpc3MiOiJodHRwczpcL1wvc2lnbmluLmF3cy5hbWF6b24uY29tXC9zaWduaW4iLCJzZXNzaW9uX2FybiI6ImFybjphd3M6c3RzOjo3MjE3ODE2MDM3NTU6YXNzdW1lZC1yb2xlXC9BZG1pblwvc2hvdmxpYS1Jc2VuZ2FyZCIsImV4cCI6MTc2MTE2Nzk0NiwiaWF0IjoxNzYxMTY3MDQ2fQ.EzySTg0K11hwQtIYtcBcnNMmX33F6XrVqXsk8WyTWjYcMQxaMnqXebLwBQBCRZha05hZiIZ5xPVCBIt7hZGyymurSfOL72cz69xHUH6u7rwu8vn10UKLHfyKLneKBlmJ", + "dpopKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIPt/u8InPLpQeQLJTvVX+sNDzni8vMDMt3Liu+nMBigfoAoGCCqGSM49\nAwEHoUQDQgAEILkGG7rNOnxiIJlMgimY1UPP8eDMFP0DAY6WGjngP4bvTAiUCQ/I\nffut2379uP+OBCm2ovGpBOJRgrl1RspUOQ==\n-----END EC PRIVATE KEY-----\n" + } + }, + "outcomes": [ + { + "result": "error", + "message": "missing `clientId`" + } + ] + }, + { + "documentation": "Failure - Missing dpopKey", + "configContents": "[profile signin]\nlogin_session = arn:aws:sts::012345678910:assumed-role/Admin/admin\n", + "cacheContents": { + "4b0ba8f99f075c0633e122fd73346ce203a3faf18ea0310eb2d29df1bab2e255.json": { + "accessToken": { + "accessKeyId": "AKIAIOSFODNN7EXAMPLE", + "secretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + "sessionToken": "AQoEXAMPLEH4aoAH0gNCAPyJxz4BlCFFxWNE1OPTgk5TthT+FvwqnKwRcOIfrRh3c/LTo6UDdyJwOOvEVPvLXCrrrUtdnniCEXAMPLE/IvU1dYUg2RVAJBanLiHb4IgRmpRV3zrkuWJOgQs8IZZaIv2BXIa2R4OlgkBN9bkUDNCJiBeb/AXlzBBko7b15fjrBs2+cTQtpZ3CYWFXG8C5zqx37wnOE49mRl/+OtkIKGO7fAE", + "accountId": "012345678901", + "expiresAt": "2020-01-01T00:00:00Z" + }, + "clientId": "arn:aws:signin:::devtools/same-device", + "refreshToken": "valid_refresh_token_101112", + "idToken": "eyJraWQiOiI1MzYxMjY2ZS1mNjI5LTQ0ZGQtOTA1My1jYzJkNTM1OTJiOTIiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiJhcm46YXdzOnN0czo6NzIxNzgxNjAzNzU1OmFzc3VtZWQtcm9sZVwvQWRtaW5cL3Nob3ZsaWEtSXNlbmdhcmQiLCJhdWQiOiJhcm46YXdzOnNpZ25pbjo6OmNsaVwvc2FtZS1kZXZpY2UiLCJpc3MiOiJodHRwczpcL1wvc2lnbmluLmF3cy5hbWF6b24uY29tXC9zaWduaW4iLCJzZXNzaW9uX2FybiI6ImFybjphd3M6c3RzOjo3MjE3ODE2MDM3NTU6YXNzdW1lZC1yb2xlXC9BZG1pblwvc2hvdmxpYS1Jc2VuZ2FyZCIsImV4cCI6MTc2MTE2Nzk0NiwiaWF0IjoxNzYxMTY3MDQ2fQ.EzySTg0K11hwQtIYtcBcnNMmX33F6XrVqXsk8WyTWjYcMQxaMnqXebLwBQBCRZha05hZiIZ5xPVCBIt7hZGyymurSfOL72cz69xHUH6u7rwu8vn10UKLHfyKLneKBlmJ" + } + }, + "outcomes": [ + { + "result": "error", + "message": "missing `dpopKey`" + } + ] + }, + { + "documentation": "Success - Expired token triggers successful refresh", + "configContents": "[profile signin]\nlogin_session = arn:aws:sts::012345678910:assumed-role/Admin/admin\n", + "cacheContents": { + "4b0ba8f99f075c0633e122fd73346ce203a3faf18ea0310eb2d29df1bab2e255.json": { + "accessToken": { + "accessKeyId": "OLDEXPIREDKEY", + "secretAccessKey": "oldExpiredSecretKey", + "sessionToken": "oldExpiredSessionToken", + "accountId": "012345678901", + "expiresAt": "2020-01-01T00:00:00Z" + }, + "clientId": "arn:aws:signin:::devtools/same-device", + "refreshToken": "valid_refresh_token", + "idToken": "eyJraWQiOiI1MzYxMjY2ZS1mNjI5LTQ0ZGQtOTA1My1jYzJkNTM1OTJiOTIiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiJhcm46YXdzOnN0czo6NzIxNzgxNjAzNzU1OmFzc3VtZWQtcm9sZVwvQWRtaW5cL3Nob3ZsaWEtSXNlbmdhcmQiLCJhdWQiOiJhcm46YXdzOnNpZ25pbjo6OmNsaVwvc2FtZS1kZXZpY2UiLCJpc3MiOiJodHRwczpcL1wvc2lnbmluLmF3cy5hbWF6b24uY29tXC9zaWduaW4iLCJzZXNzaW9uX2FybiI6ImFybjphd3M6c3RzOjo3MjE3ODE2MDM3NTU6YXNzdW1lZC1yb2xlXC9BZG1pblwvc2hvdmxpYS1Jc2VuZ2FyZCIsImV4cCI6MTc2MTE2Nzk0NiwiaWF0IjoxNzYxMTY3MDQ2fQ.EzySTg0K11hwQtIYtcBcnNMmX33F6XrVqXsk8WyTWjYcMQxaMnqXebLwBQBCRZha05hZiIZ5xPVCBIt7hZGyymurSfOL72cz69xHUH6u7rwu8vn10UKLHfyKLneKBlmJ", + "dpopKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIPt/u8InPLpQeQLJTvVX+sNDzni8vMDMt3Liu+nMBigfoAoGCCqGSM49\nAwEHoUQDQgAEILkGG7rNOnxiIJlMgimY1UPP8eDMFP0DAY6WGjngP4bvTAiUCQ/I\nffut2379uP+OBCm2ovGpBOJRgrl1RspUOQ==\n-----END EC PRIVATE KEY-----\n" + } + }, + "mockApiCalls": [ + { + "request": { + "tokenInput": { + "clientId": "arn:aws:signin:::devtools/same-device", + "refreshToken": "valid_refresh_token", + "grantType": "refresh_token" + } + }, + "response": { + "tokenOutput": { + "accessToken": { + "accessKeyId": "NEWREFRESHEDKEY", + "secretAccessKey": "newRefreshedSecretKey", + "sessionToken": "newRefreshedSessionToken" + }, + "refreshToken": "new_refresh_token", + "expiresIn": 900 + } + } + } + ], + "outcomes": [ + { + "result": "credentials", + "accessKeyId": "NEWREFRESHEDKEY", + "secretAccessKey": "newRefreshedSecretKey", + "sessionToken": "newRefreshedSessionToken", + "accountId": "012345678901", + "expiresAt": "2025-11-19T00:15:00Z" + }, + { + "result": "cacheContents", + "4b0ba8f99f075c0633e122fd73346ce203a3faf18ea0310eb2d29df1bab2e255.json": { + "accessToken": { + "accessKeyId": "NEWREFRESHEDKEY", + "secretAccessKey": "newRefreshedSecretKey", + "sessionToken": "newRefreshedSessionToken", + "accountId": "012345678901", + "expiresAt": "2025-11-19T00:15:00Z" + }, + "clientId": "arn:aws:signin:::devtools/same-device", + "refreshToken": "new_refresh_token", + "idToken": "eyJraWQiOiI1MzYxMjY2ZS1mNjI5LTQ0ZGQtOTA1My1jYzJkNTM1OTJiOTIiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiJhcm46YXdzOnN0czo6NzIxNzgxNjAzNzU1OmFzc3VtZWQtcm9sZVwvQWRtaW5cL3Nob3ZsaWEtSXNlbmdhcmQiLCJhdWQiOiJhcm46YXdzOnNpZ25pbjo6OmNsaVwvc2FtZS1kZXZpY2UiLCJpc3MiOiJodHRwczpcL1wvc2lnbmluLmF3cy5hbWF6b24uY29tXC9zaWduaW4iLCJzZXNzaW9uX2FybiI6ImFybjphd3M6c3RzOjo3MjE3ODE2MDM3NTU6YXNzdW1lZC1yb2xlXC9BZG1pblwvc2hvdmxpYS1Jc2VuZ2FyZCIsImV4cCI6MTc2MTE2Nzk0NiwiaWF0IjoxNzYxMTY3MDQ2fQ.EzySTg0K11hwQtIYtcBcnNMmX33F6XrVqXsk8WyTWjYcMQxaMnqXebLwBQBCRZha05hZiIZ5xPVCBIt7hZGyymurSfOL72cz69xHUH6u7rwu8vn10UKLHfyKLneKBlmJ", + "dpopKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIPt/u8InPLpQeQLJTvVX+sNDzni8vMDMt3Liu+nMBigfoAoGCCqGSM49\nAwEHoUQDQgAEILkGG7rNOnxiIJlMgimY1UPP8eDMFP0DAY6WGjngP4bvTAiUCQ/I\nffut2379uP+OBCm2ovGpBOJRgrl1RspUOQ==\n-----END EC PRIVATE KEY-----\n" + } + } + ] + }, + { + "documentation": "Failure - Expired token triggers failed refresh", + "configContents": "[profile signin]\nlogin_session = arn:aws:sts::012345678910:assumed-role/Admin/admin\n", + "cacheContents": { + "4b0ba8f99f075c0633e122fd73346ce203a3faf18ea0310eb2d29df1bab2e255.json": { + "accessToken": { + "accessKeyId": "OLDEXPIREDKEY", + "secretAccessKey": "oldExpiredSecretKey", + "sessionToken": "oldExpiredSessionToken", + "accountId": "012345678901", + "expiresAt": "2020-01-01T00:00:00Z" + }, + "clientId": "arn:aws:signin:::devtools/same-device", + "refreshToken": "expired_refresh_token", + "idToken": "eyJraWQiOiI1MzYxMjY2ZS1mNjI5LTQ0ZGQtOTA1My1jYzJkNTM1OTJiOTIiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiJhcm46YXdzOnN0czo6NzIxNzgxNjAzNzU1OmFzc3VtZWQtcm9sZVwvQWRtaW5cL3Nob3ZsaWEtSXNlbmdhcmQiLCJhdWQiOiJhcm46YXdzOnNpZ25pbjo6OmNsaVwvc2FtZS1kZXZpY2UiLCJpc3MiOiJodHRwczpcL1wvc2lnbmluLmF3cy5hbWF6b24uY29tXC9zaWduaW4iLCJzZXNzaW9uX2FybiI6ImFybjphd3M6c3RzOjo3MjE3ODE2MDM3NTU6YXNzdW1lZC1yb2xlXC9BZG1pblwvc2hvdmxpYS1Jc2VuZ2FyZCIsImV4cCI6MTc2MTE2Nzk0NiwiaWF0IjoxNzYxMTY3MDQ2fQ.EzySTg0K11hwQtIYtcBcnNMmX33F6XrVqXsk8WyTWjYcMQxaMnqXebLwBQBCRZha05hZiIZ5xPVCBIt7hZGyymurSfOL72cz69xHUH6u7rwu8vn10UKLHfyKLneKBlmJ", + "dpopKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIPt/u8InPLpQeQLJTvVX+sNDzni8vMDMt3Liu+nMBigfoAoGCCqGSM49\nAwEHoUQDQgAEILkGG7rNOnxiIJlMgimY1UPP8eDMFP0DAY6WGjngP4bvTAiUCQ/I\nffut2379uP+OBCm2ovGpBOJRgrl1RspUOQ==\n-----END EC PRIVATE KEY-----\n" + } + }, + "mockApiCalls": [ + { + "request": { + "tokenInput": { + "clientId": "arn:aws:signin:::devtools/same-device", + "refreshToken": "expired_refresh_token", + "grantType": "refresh_token" + } + }, + "responseCode": 400 + } + ], + "outcomes": [ + { + "result": "error", + "message": "Login token for login-session: arn:aws:sts::012345678910:assumed-role/Admin/admin is expired" + } + ] + } +] +""" diff --git a/aws-runtime/aws-http/api/aws-http.api b/aws-runtime/aws-http/api/aws-http.api index fea5bdc046c..77ecb6f8bd3 100644 --- a/aws-runtime/aws-http/api/aws-http.api +++ b/aws-runtime/aws-http/api/aws-http.api @@ -185,8 +185,10 @@ public final class aws/sdk/kotlin/runtime/http/interceptors/businessmetrics/AwsB public static final field CREDENTIALS_HTTP Laws/sdk/kotlin/runtime/http/interceptors/businessmetrics/AwsBusinessMetric$Credentials; public static final field CREDENTIALS_IMDS Laws/sdk/kotlin/runtime/http/interceptors/businessmetrics/AwsBusinessMetric$Credentials; public static final field CREDENTIALS_JVM_SYSTEM_PROPERTIES Laws/sdk/kotlin/runtime/http/interceptors/businessmetrics/AwsBusinessMetric$Credentials; + public static final field CREDENTIALS_LOGIN Laws/sdk/kotlin/runtime/http/interceptors/businessmetrics/AwsBusinessMetric$Credentials; public static final field CREDENTIALS_PROCESS Laws/sdk/kotlin/runtime/http/interceptors/businessmetrics/AwsBusinessMetric$Credentials; public static final field CREDENTIALS_PROFILE Laws/sdk/kotlin/runtime/http/interceptors/businessmetrics/AwsBusinessMetric$Credentials; + public static final field CREDENTIALS_PROFILE_LOGIN Laws/sdk/kotlin/runtime/http/interceptors/businessmetrics/AwsBusinessMetric$Credentials; public static final field CREDENTIALS_PROFILE_NAMED_PROVIDER Laws/sdk/kotlin/runtime/http/interceptors/businessmetrics/AwsBusinessMetric$Credentials; public static final field CREDENTIALS_PROFILE_PROCESS Laws/sdk/kotlin/runtime/http/interceptors/businessmetrics/AwsBusinessMetric$Credentials; public static final field CREDENTIALS_PROFILE_SOURCE_PROFILE Laws/sdk/kotlin/runtime/http/interceptors/businessmetrics/AwsBusinessMetric$Credentials; diff --git a/aws-runtime/aws-http/common/src/aws/sdk/kotlin/runtime/http/interceptors/businessmetrics/AwsBusinessMetricsUtils.kt b/aws-runtime/aws-http/common/src/aws/sdk/kotlin/runtime/http/interceptors/businessmetrics/AwsBusinessMetricsUtils.kt index 239686ee3df..04bc854e248 100644 --- a/aws-runtime/aws-http/common/src/aws/sdk/kotlin/runtime/http/interceptors/businessmetrics/AwsBusinessMetricsUtils.kt +++ b/aws-runtime/aws-http/common/src/aws/sdk/kotlin/runtime/http/interceptors/businessmetrics/AwsBusinessMetricsUtils.kt @@ -84,6 +84,8 @@ public enum class AwsBusinessMetric(public override val identifier: String) : Bu CREDENTIALS_PROCESS("w"), CREDENTIALS_HTTP("z"), CREDENTIALS_IMDS("0"), + CREDENTIALS_PROFILE_LOGIN("AC"), + CREDENTIALS_LOGIN("AD"), } override fun toString(): String = identifier