From 6000440a9a5bdb94fc53f79f3c4b431223130f52 Mon Sep 17 00:00:00 2001 From: Xinsong Cui Date: Thu, 6 Nov 2025 15:30:44 -0500 Subject: [PATCH 01/31] add login token provider --- aws-runtime/aws-config/api/aws-config.api | 24 + aws-runtime/aws-config/build.gradle.kts | 35 + .../credentials/LoginCredentialsProvider.kt | 44 + .../auth/credentials/LoginTokenProvider.kt | 458 ++++++ .../credentials/ProfileCredentialsProvider.kt | 5 + .../auth/credentials/SsoTokenProvider.kt | 2 +- .../auth/credentials/profile/LeafProvider.kt | 13 + .../auth/credentials/profile/ProfileChain.kt | 12 + .../LoginCredentialsProviderTest.kt | 147 ++ .../credentials/LoginTokenProviderTest.kt | 432 ++++++ aws-runtime/aws-http/api/aws-http.api | 2 + .../AwsBusinessMetricsUtils.kt | 2 + codegen/sdk/aws-models/sign-in.json | 1236 +++++++++++++++++ 13 files changed, 2411 insertions(+), 1 deletion(-) create mode 100644 aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/LoginCredentialsProvider.kt create mode 100644 aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/LoginTokenProvider.kt create mode 100644 aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/LoginCredentialsProviderTest.kt create mode 100644 aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/LoginTokenProviderTest.kt create mode 100644 codegen/sdk/aws-models/sign-in.json diff --git a/aws-runtime/aws-config/api/aws-config.api b/aws-runtime/aws-config/api/aws-config.api index 6d6878eb35f..70a6547057d 100644 --- a/aws-runtime/aws-config/api/aws-config.api +++ b/aws-runtime/aws-config/api/aws-config.api @@ -92,11 +92,35 @@ 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/CredentialsProvider { + public fun (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;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 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 fun resolve (Laws/smithy/kotlin/runtime/collections/Attributes;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class aws/sdk/kotlin/runtime/auth/credentials/LoginTokenProvider : aws/smithy/kotlin/runtime/auth/awscredentials/CredentialsProvider { + public synthetic fun (Ljava/lang/String;JLaws/smithy/kotlin/runtime/http/engine/HttpClientEngine;Laws/smithy/kotlin/runtime/util/PlatformProvider;Laws/smithy/kotlin/runtime/time/Clock;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;JLaws/smithy/kotlin/runtime/http/engine/HttpClientEngine;Laws/smithy/kotlin/runtime/util/PlatformProvider;Laws/smithy/kotlin/runtime/time/Clock;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getHttpClient ()Laws/smithy/kotlin/runtime/http/engine/HttpClientEngine; + public final fun getLoginSessionName ()Ljava/lang/String; + public final fun getPlatformProvider ()Laws/smithy/kotlin/runtime/util/PlatformProvider; + public final fun getRefreshBufferWindow-UwyO8pc ()J + 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 diff --git a/aws-runtime/aws-config/build.gradle.kts b/aws-runtime/aws-config/build.gradle.kts index 3d3759115d8..b1da7cae900 100644 --- a/aws-runtime/aws-config/build.gradle.kts +++ b/aws-runtime/aws-config/build.gradle.kts @@ -188,6 +188,41 @@ smithyBuild { """, ) } + //Note: I removed the smoke test section in model to make build pass + create("signin-credentials-provider") { + imports = listOf( + awsModelFile("sign-in.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..59eeca9533b --- /dev/null +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/LoginCredentialsProvider.kt @@ -0,0 +1,44 @@ +/* + * 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.http.interceptors.businessmetrics.AwsBusinessMetric +import aws.sdk.kotlin.runtime.http.interceptors.businessmetrics.withBusinessMetric + +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 kotlin.coroutines.coroutineContext + +private const val PROVIDER_NAME = "LOGIN" + +public class LoginCredentialsProvider public constructor( + public val loginSession: String, + public val httpClient: HttpClientEngine? = null, + public val platformProvider: PlatformProvider = PlatformProvider.System, + private val clock: Clock = Clock.System, +) : CredentialsProvider { +// private val signinTokenProvider = //Do we need to check nullibility here +// SigninTokenProvider(signinSession, enpoint = null, httpClient = httpClient, platformProvider = platformProvider, clock = clock) +// private val loginTokenProvider = +// LoginTokenProvider(loginSession, httpClient = httpClient, platformProvider = platformProvider, clock = clock) + + override suspend fun resolve(attributes: Attributes): Credentials { + val logger = coroutineContext.logger() + + val loginTokenProvider = + LoginTokenProvider(loginSession, httpClient = httpClient, platformProvider = platformProvider, clock = clock) + + 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) + } +} 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..a8945f6b104 --- /dev/null +++ b/aws-runtime/aws-config/common/src/aws/sdk/kotlin/runtime/auth/credentials/LoginTokenProvider.kt @@ -0,0 +1,458 @@ +/* + * 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.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.net.url.Url +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.telemetry.telemetryProvider +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 aws.smithy.kotlin.runtime.text.encoding.encodeBase64String +import kotlin.coroutines.coroutineContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.Base64.Default.UrlSafe + +private const val DEFAULT_SIGNIN_TOKEN_REFRESH_BUFFER_SECONDS = 60 * 5 +//internal class PrintAllHeadersInterceptor : HttpInterceptor { +// +// override suspend fun modifyBeforeTransmit(context: ProtocolRequestInterceptorContext): HttpRequest { +// context.protocolRequest.headers.entries().forEach { (name, values) -> +// println("$name: ${values.joinToString(", ")}") +// } +// return context.protocolRequest +// } +//} + + +// HTTP interceptor that adds DPoP (Demonstration of Proof-of-Possession) headers to requests. +internal class DpopInterceptor(private val dpopKeyPem: String) : HttpInterceptor { + override suspend fun modifyBeforeTransmit(context: ProtocolRequestInterceptorContext): HttpRequest { + val endpoint = extractRequestEndpoint(context.protocolRequest) + val dpopHeader = generateDpopProof(dpopKeyPem, endpoint) + + val request = context.protocolRequest.toBuilder() + println("Setting DPoP header: $dpopHeader") + + request.header("DPoP", dpopHeader) + return request.build() + } + + // extracts the full request endpoint URL for use in DPoP proof generation. + private fun extractRequestEndpoint(request: HttpRequest): String { + val url = request.url + return url.toString() +// val port = if (isStandardPort(url.scheme, url.port)) "" else ":${url.port}" +// return "${url.scheme}://${url.host}$port${url.encodedPath}" + } +} + +/** + * 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. + * See [Configure AWS Login](doc link TBD) + * + * 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 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 + */ +public class LoginTokenProvider ( + public val loginSessionName: String, + public val refreshBufferWindow: Duration = DEFAULT_SIGNIN_TOKEN_REFRESH_BUFFER_SECONDS.seconds, + public val httpClient: HttpClientEngine? = null, + public val platformProvider: PlatformProvider = PlatformProvider.System, + private val clock: Clock = Clock.System, +): 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, + accountId = token.accountId + ) + } + + private suspend fun getToken(attributes: Attributes): LoginToken { + val token = readLoginTokenFromCache(loginSessionName, platformProvider) + + if (clock.now() < (token.expiresAt - refreshBufferWindow)) { + coroutineContext.debug { "using cached token for login-session: $loginSessionName" } + return token + } + + // token is within expiry window + if (token.canRefresh) { + return attemptRefresh(token) + } + + return 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() + return token + } + + 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("~", ".aws", "login", "cache", cacheKey), platformProvider) + try { + //println("attempting to write refreshed token") + val contents = serializeLoginToken(refreshed) + //println("contents: "+ contents.decodeToString()) + platformProvider.writeFile(filepath, contents) + } catch (ex: Exception) { + coroutineContext.debug(ex) { "failed to write refreshed token back to disk at $filepath" } + } + } + + private fun throwTokenExpired(cause: Throwable? = null): Nothing = throw InvalidLoginTokenException("Login token for login-session: $loginSessionName is expired", cause) + + private suspend fun refreshToken(oldToken: LoginToken): LoginToken { + val telemetry = coroutineContext.telemetryProvider + println("attempting to refresh token") + SigninClient.fromEnvironment { + httpClient = this@LoginTokenProvider.httpClient + telemetryProvider = telemetry + endpointUrl = Url.parse("https://ap-northeast-1.aws-signin-testing.amazon.com") //TODO: use testing endpoint, remove this once service prod endpoint is available + interceptors += DpopInterceptor(oldToken.dpopKey) // note for implementer: this is for writing DpopProof in request header instead of sending in request + //interceptors += PrintAllHeadersInterceptor() + }.use { client -> + val result = client.createOAuth2Token { + dpopProof = generateDpopProof(oldToken.dpopKey!!, "https://ap-northeast-1.aws-signin-testing.amazon.com/v1/token") //TODO: remove this line once login model remove dpopproof field + tokenInput { + clientId = oldToken.clientId + grantType = "refresh_token" + refreshToken = oldToken.refreshToken + } + } + + //TODO: use model provided exception: + // If the CreateOAuth2Token call returns a TBD error with the TBD member set to TBD, then the SDK MUST return an error with TBD wording. + return try { + 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) { + throw InvalidLoginTokenException("Failed to parse token response", 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 coordinates. + * Supports both "EC PRIVATE KEY" and "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("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replace("\\s".toRegex(), "") + .replace("\n", "") + .replace("\r", "") + + val der = base64.decodeBase64Bytes() + println("DER hex: ${der.encodeToHex()}") + // 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 + println("Found 0x04 at position $i, public key starts at ${i + 1}") + break + } + } + + val remainingBytes = der.size - publicKeyStart + val coordLen = remainingBytes / 2 + println("Public key section: ${der.copyOfRange(publicKeyStart - 1, der.size).encodeToHex()}") + + val x = der.copyOfRange(publicKeyStart, publicKeyStart + coordLen).padTo32() + val y = der.copyOfRange(publicKeyStart + coordLen, publicKeyStart + 2 * coordLen).padTo32() + println("Raw x (${x.size} bytes): ${x.encodeToHex()}") + println("Raw y (${y.size} bytes): ${y.encodeToHex()}") + + println("d: ${d.encodeBase64String()}") + println("x: ${x.encodeBase64String()}") + println("y: ${y.encodeBase64String()}") + return ECKeyData(d, x, y) +} + +private fun ByteArray.padTo32(): ByteArray { + return if (size >= 32) { + takeLast(32).toByteArray() + } 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(xB64) + writeValue(base64UrlNoPadding.encode(ecKeyData.x)) + writeName("y") + //writeValue(yB64) + writeValue(base64UrlNoPadding.encode(ecKeyData.y)) + writeName("crv") + writeValue("P-256") + endObject() + endObject() + }.bytes + println("header: ${header?.decodeToString()}") + val payload = jsonStreamWriter().apply { + beginObject() + writeName("jti") + writeValue(Uuid.random().toString()) + writeName("htm") + writeValue("POST") + writeName("htu") + writeValue(endpoint) // hardcoded test endpoint, TODO: change it + writeName("iat") + writeValue(System.currentTimeMillis() / 1000) + endObject() + }.bytes + println("payload: ${payload?.decodeToString()}") + + val headerEncoded = base64UrlNoPadding.encode(header!!) + val payloadEncoded = base64UrlNoPadding.encode(payload!!) + val message = "$headerEncoded.$payloadEncoded" + println("message: $message") + + val privateKeyBytes = ecKeyData.d + val signature = ecdsaSecp256r1Rs(privateKeyBytes, message.encodeToByteArray()) + + println("signature hex: ${signature.encodeToHex()}") + println("signature length: ${signature.size}") + + return "$message.${ base64UrlNoPadding.encode(signature) }" +} + +internal suspend fun readLoginTokenFromCache(cacheKey: String, platformProvider: PlatformProvider): LoginToken { + val key = getLoginCacheFilename(cacheKey) + val bytes = with(platformProvider) { + val directory = getenv("AWS_LOGIN_IN_CACHE_DIRECTORY") ?: filepath("~", ".aws", "login", "cache") + val defaultCacheLocation = normalizePath(directory, 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, +) + +/** + * Test if a token has the components to allow it to be refreshed for a new one + */ +private val LoginToken.canRefresh: Boolean + get() = clientId != null && dpopKey != null && refreshToken != null + +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 + + try { + lexer.nextTokenOf() + loop@while (true) { + when (val token = lexer.nextToken()) { + is JsonToken.EndObject -> break@loop + is JsonToken.Name -> when (token.value) { + "accessToken" -> { + 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 (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..ff289164f29 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).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..4ec5df60d59 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 for 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..c2498c709b5 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(config: AwsSharedConfig): 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(config) } .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..8aa314a620c --- /dev/null +++ b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/LoginCredentialsProviderTest.kt @@ -0,0 +1,147 @@ +/* + * 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.HttpTestConnectionBuilder +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", + httpClient = engine, + platformProvider = testPlatform, + clock = testClock, + ) + + val actual = provider.resolve() + val expected = credentials( + "AKID", + "secret", + "session-token", + expiration = expectedExpiration, + 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..96bac8e97b1 --- /dev/null +++ b/aws-runtime/aws-config/common/test/aws/sdk/kotlin/runtime/auth/credentials/LoginTokenProviderTest.kt @@ -0,0 +1,432 @@ +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.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.Success( + 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() } + ) + else -> TestOutcome.Error + } + } + return LoginTestCase(name, configContents, cacheContents, mockApiCalls, outcomes) + } + } + } + + private sealed class TestOutcome { + data class Success( + val accessKeyId: String, + val secretAccessKey: String, + val sessionToken: String, + val accountId: String, + val expiresAt: Instant + ) : TestOutcome() + + data class CacheContents( + val cacheContents: Map + ) : TestOutcome() + + object Error : TestOutcome() + } + + @Test + fun testLoginTokenCacheBehavior() = runTest { + 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 originalTestCase = testList[idx].jsonObject + val mockApiCalls = originalTestCase["mockApiCalls"]?.jsonArray + + val httpClient = if (testCase.mockApiCalls != null) { + buildTestConnection { + 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, + refreshBufferWindow = 0.seconds, + httpClient = httpClient, + platformProvider = testPlatform, + clock = testClock + ) + + testCase.outcomes.forEach { expectedOutcome -> + when (expectedOutcome) { + is TestOutcome.Success -> { + // 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") + println("✓ Test passed: ${testCase.name}") + } + + 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") + } + println("✓ Cache contents verified: ${testCase.name}") + } + + is TestOutcome.Error -> { + assertFails("[idx=$idx]: $testCase") { + tokenProvider.resolve() + } + } + } + } + } + } +} + +// 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 PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg+PNauWi/ihtwHHbq\n1tgc8Vgpwx0qQlNSN38y+z0igWehRANCAAR2Ntw6BXJ1v8jb9XjzKZJ+gL5f/3Jq\nIqiH2PUGKWxoFwNlcNB83FivEXEzlTbuCQK5OezOYb3gbvHuzKkB0nDX\n-----END PRIVATE KEY-----" + } + }, + "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" + } + ] + }, + { + "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 PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg+PNauWi/ihtwHHbq\n1tgc8Vgpwx0qQlNSN38y+z0igWehRANCAAR2Ntw6BXJ1v8jb9XjzKZJ+gL5f/3Jq\nIqiH2PUGKWxoFwNlcNB83FivEXEzlTbuCQK5OezOYb3gbvHuzKkB0nDX\n-----END PRIVATE KEY-----" + } + }, + "outcomes": [ + { + "result": "error" + } + ] + }, + { + "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 PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg+PNauWi/ihtwHHbq\n1tgc8Vgpwx0qQlNSN38y+z0igWehRANCAAR2Ntw6BXJ1v8jb9XjzKZJ+gL5f/3Jq\nIqiH2PUGKWxoFwNlcNB83FivEXEzlTbuCQK5OezOYb3gbvHuzKkB0nDX\n-----END PRIVATE KEY-----" + } + }, + "outcomes": [ + { + "result": "error" + } + ] + }, + { + "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 PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg+PNauWi/ihtwHHbq\n1tgc8Vgpwx0qQlNSN38y+z0igWehRANCAAR2Ntw6BXJ1v8jb9XjzKZJ+gL5f/3Jq\nIqiH2PUGKWxoFwNlcNB83FivEXEzlTbuCQK5OezOYb3gbvHuzKkB0nDX\n-----END PRIVATE KEY-----" + } + }, + "outcomes": [ + { + "result": "error" + } + ] + }, + { + "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" + } + ] + }, + { + "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 PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg+PNauWi/ihtwHHbq\n1tgc8Vgpwx0qQlNSN38y+z0igWehRANCAAR2Ntw6BXJ1v8jb9XjzKZJ+gL5f/3Jq\nIqiH2PUGKWxoFwNlcNB83FivEXEzlTbuCQK5OezOYb3gbvHuzKkB0nDX\n-----END PRIVATE KEY-----" + } + }, + "mockApiCalls": [ + { + "request": { + "dpopProof": "mock_dpop_proof", + "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 PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg+PNauWi/ihtwHHbq\n1tgc8Vgpwx0qQlNSN38y+z0igWehRANCAAR2Ntw6BXJ1v8jb9XjzKZJ+gL5f/3Jq\nIqiH2PUGKWxoFwNlcNB83FivEXEzlTbuCQK5OezOYb3gbvHuzKkB0nDX\n-----END PRIVATE KEY-----" + } + } + ] + }, + { + "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 PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg+PNauWi/ihtwHHbq\n1tgc8Vgpwx0qQlNSN38y+z0igWehRANCAAR2Ntw6BXJ1v8jb9XjzKZJ+gL5f/3Jq\nIqiH2PUGKWxoFwNlcNB83FivEXEzlTbuCQK5OezOYb3gbvHuzKkB0nDX\n-----END PRIVATE KEY-----" + } + }, + "mockApiCalls": [ + { + "request": { + "dpopProof": "mock_dpop_proof", + "tokenInput": { + "clientId": "arn:aws:signin:::devtools/same-device", + "refreshToken": "expired_refresh_token", + "grantType": "refresh_token" + } + }, + "responseCode": 400 + } + ], + "outcomes": [ + { + "result": "error" + } + ] + } +] +""" \ No newline at end of file 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..1e0d04937d7 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("TBD"), + CREDENTIALS_LOGIN("TBD") } override fun toString(): String = identifier diff --git a/codegen/sdk/aws-models/sign-in.json b/codegen/sdk/aws-models/sign-in.json new file mode 100644 index 00000000000..804bfe7f80d --- /dev/null +++ b/codegen/sdk/aws-models/sign-in.json @@ -0,0 +1,1236 @@ +{ + "smithy": "2.0", + "shapes": { + "com.amazonaws.signin#AccessToken": { + "type": "structure", + "members": { + "accessKeyId": { + "target": "smithy.api#String", + "traits": { + "smithy.api#documentation": "AWS access key ID for temporary credentials", + "smithy.api#jsonName": "accessKeyId", + "smithy.api#required": {} + } + }, + "secretAccessKey": { + "target": "smithy.api#String", + "traits": { + "smithy.api#documentation": "AWS secret access key for temporary credentials", + "smithy.api#jsonName": "secretAccessKey", + "smithy.api#required": {} + } + }, + "sessionToken": { + "target": "smithy.api#String", + "traits": { + "smithy.api#documentation": "AWS session token for temporary credentials", + "smithy.api#jsonName": "sessionToken", + "smithy.api#required": {} + } + } + }, + "traits": { + "smithy.api#documentation": "AWS credentials structure containing temporary access credentials\n\nThe scoped-down, 15 minute duration AWS credentials.\nScoping down will be based on CLI policy (CLI team needs to create it).\nSimilar to cloud shell implementation.", + "smithy.api#sensitive": {} + } + }, + "com.amazonaws.signin#AuthorizationCode": { + "type": "string", + "traits": { + "smithy.api#documentation": "Authorization code received from AWS Sign-In /v1/authorize endpoint\n\nThe authorization code received from AWS Sign-In from /v1/authorize.\nUsed in auth code redemption flow only.", + "smithy.api#length": { + "min": 1, + "max": 512 + } + } + }, + "com.amazonaws.signin#ClientId": { + "type": "string", + "traits": { + "smithy.api#documentation": "Client identifier pattern for AWS Sign-In CLI clients\n\nThe ARN used by client as part of Sign-In onboarding. Expected values:\n- aws:signin:::cli/same-device (for CLI login on same device)\n- aws:signin:::cli/cross-device (for cross-device CLI login)\n- aws:signin:::cli/in-band (for in-band CLI login)\n- aws:signin:::cli/out-of-band (for out-of-band CLI login)\n\nThis will be finalized after consulting with UX as this is visible to end customer.", + "smithy.api#pattern": "^aws:signin:::cli/(same-device|cross-device|in-band|out-of-band)$" + } + }, + "com.amazonaws.signin#CodeVerifier": { + "type": "string", + "traits": { + "smithy.api#documentation": "PKCE code verifier for OAuth 2.0 security\n\nPKCE code verifier to prove possession of the original code challenge.\nUsed to prevent authorization code interception attacks in public clients.\nMust be 43-128 characters using unreserved characters [A-Z] / [a-z] / [0-9] / \"-\" / \".\" / \"_\" / \"~\"", + "smithy.api#length": { + "min": 43, + "max": 128 + }, + "smithy.api#pattern": "^[A-Za-z0-9\\-._~]+$" + } + }, + "com.amazonaws.signin#CreateOAuth2Token": { + "type": "operation", + "input": { + "target": "com.amazonaws.signin#CreateOAuth2TokenRequest" + }, + "output": { + "target": "com.amazonaws.signin#CreateOAuth2TokenResponse" + }, + "errors": [ + { + "target": "com.amazonaws.signin#ForbiddenError" + }, + { + "target": "com.amazonaws.signin#InvalidRequestError" + }, + { + "target": "com.amazonaws.signin#TooManyRequestsError" + }, + { + "target": "com.amazonaws.signin#UnauthorizedError" + } + ], + "traits": { + "smithy.api#auth": [], + "smithy.api#documentation": "CreateOAuth2Token API\n\nPath: /v1/token\nRequest Method: POST\nContent-Type: application/json or application/x-www-form-urlencoded\n\nThis API implements OAuth 2.0 flows for AWS Sign-In CLI clients, supporting both:\n1. Authorization code redemption (grant_type=authorization_code) - NOT idempotent\n2. Token refresh (grant_type=refresh_token) - Idempotent within token validity window\n\nThe operation behavior is determined by the grant_type parameter in the request body:\n\n**Authorization Code Flow (NOT Idempotent):**\n- DPoP proof JWT header for demonstrating proof-of-possession of private key\n- JSON or form-encoded body with client_id, grant_type=authorization_code, code, redirect_uri, code_verifier\n- Returns access_token, token_type, expires_in, refresh_token, and id_token\n- Each authorization code can only be used ONCE for security (prevents replay attacks)\n\n**Token Refresh Flow (Idempotent):**\n- DPoP proof JWT header (same private key as original auth_code redemption)\n- JSON or form-encoded body with client_id, grant_type=refresh_token, refresh_token\n- Returns access_token, token_type, expires_in, and refresh_token (no id_token)\n- Multiple calls with same refresh_token return consistent results within validity window\n\nAuthentication and authorization:\n- Confidential clients: sigv4 signing required with signin:ExchangeToken permissions\n- CLI clients (public): authn/authz skipped based on client_id & grant_type\n\nNote: This operation cannot be marked as @idempotent because it handles both idempotent\n(token refresh) and non-idempotent (auth code redemption) flows in a single endpoint.", + "smithy.api#http": { + "method": "POST", + "uri": "/v1/token" + }, + "smithy.test#smokeTests": [ + { + "id": "TokenOperationSmokeTest", + "params": { + "dpopProof": "test-dpop-proof", + "tokenInput": { + "clientId": "aws:signin:::cli/same-device", + "grantType": "authorization_code", + "code": "test-code", + "redirectUri": "https://example.com", + "codeVerifier": "test-code-verifier-1234567890abcdefghijklmnop" + } + }, + "vendorParams": {}, + "expect": { + "failure": {} + } + } + ] + } + }, + "com.amazonaws.signin#CreateOAuth2TokenRequest": { + "type": "structure", + "members": { + "dpopProof": { + "target": "com.amazonaws.signin#DPoPProof", + "traits": { + "smithy.api#documentation": "DPoP proof JWT header for demonstrating proof-of-possession of the private key\n\nHeader format: {\"typ\": \"dpop+jwt\", \"alg\": \"ES256\", \"jwk\": {...}}\nPayload format: {\"htm\": \"POST\", \"htu\": \"https://server.example.com/token\", \"iat\": timestamp, \"jti\": \"unique-id\"}\nMust be signed with the private key corresponding to the embedded jwk", + "smithy.api#httpHeader": "DPoP", + "smithy.api#required": {} + } + }, + "tokenInput": { + "target": "com.amazonaws.signin#CreateOAuth2TokenRequestBody", + "traits": { + "smithy.api#documentation": "Flattened token operation inputs\nThe specific operation is determined by grant_type in the request body", + "smithy.api#httpPayload": {}, + "smithy.api#required": {} + } + } + }, + "traits": { + "smithy.api#documentation": "Input structure for CreateOAuth2Token operation\n\nContains flattened token operation inputs for both authorization code and refresh token flows.\nThe operation type is determined by the grant_type parameter in the request body.", + "smithy.api#input": {} + } + }, + "com.amazonaws.signin#CreateOAuth2TokenRequestBody": { + "type": "structure", + "members": { + "clientId": { + "target": "com.amazonaws.signin#ClientId", + "traits": { + "smithy.api#documentation": "The client identifier (ARN) used during Sign-In onboarding\nRequired for both authorization code and refresh token flows", + "smithy.api#jsonName": "clientId", + "smithy.api#required": {} + } + }, + "grantType": { + "target": "com.amazonaws.signin#GrantType", + "traits": { + "smithy.api#documentation": "OAuth 2.0 grant type - determines which flow is used\nMust be \"authorization_code\" or \"refresh_token\"", + "smithy.api#jsonName": "grantType", + "smithy.api#required": {} + } + }, + "code": { + "target": "com.amazonaws.signin#AuthorizationCode", + "traits": { + "smithy.api#documentation": "The authorization code received from /v1/authorize\nRequired only when grant_type=authorization_code" + } + }, + "redirectUri": { + "target": "com.amazonaws.signin#RedirectUri", + "traits": { + "smithy.api#documentation": "The redirect URI that must match the original authorization request\nRequired only when grant_type=authorization_code", + "smithy.api#jsonName": "redirectUri" + } + }, + "codeVerifier": { + "target": "com.amazonaws.signin#CodeVerifier", + "traits": { + "smithy.api#documentation": "PKCE code verifier to prove possession of the original code challenge\nRequired only when grant_type=authorization_code", + "smithy.api#jsonName": "codeVerifier" + } + }, + "refreshToken": { + "target": "com.amazonaws.signin#RefreshToken", + "traits": { + "smithy.api#documentation": "The refresh token returned from auth_code redemption\nRequired only when grant_type=refresh_token", + "smithy.api#jsonName": "refreshToken" + } + } + }, + "traits": { + "smithy.api#documentation": "Request body payload for CreateOAuth2Token operation\n\nThe operation type is determined by the grant_type parameter:\n- grant_type=authorization_code: Requires code, redirect_uri, code_verifier\n- grant_type=refresh_token: Requires refresh_token" + } + }, + "com.amazonaws.signin#CreateOAuth2TokenResponse": { + "type": "structure", + "members": { + "tokenOutput": { + "target": "com.amazonaws.signin#CreateOAuth2TokenResponseBody", + "traits": { + "smithy.api#documentation": "Flattened token operation outputs\nThe specific response fields depend on the grant_type used in the request", + "smithy.api#httpPayload": {}, + "smithy.api#required": {} + } + } + }, + "traits": { + "smithy.api#documentation": "Output structure for CreateOAuth2Token operation\n\nContains flattened token operation outputs for both authorization code and refresh token flows.\nThe response content depends on the grant_type from the original request.", + "smithy.api#output": {} + } + }, + "com.amazonaws.signin#CreateOAuth2TokenResponseBody": { + "type": "structure", + "members": { + "accessToken": { + "target": "com.amazonaws.signin#AccessToken", + "traits": { + "smithy.api#documentation": "Scoped-down AWS credentials (15 minute duration)\nPresent for both authorization code redemption and token refresh", + "smithy.api#jsonName": "accessToken", + "smithy.api#required": {} + } + }, + "tokenType": { + "target": "com.amazonaws.signin#TokenType", + "traits": { + "smithy.api#documentation": "Token type indicating this is AWS SigV4 credentials\nValue is \"aws_sigv4\" for both flows", + "smithy.api#jsonName": "tokenType", + "smithy.api#required": {} + } + }, + "expiresIn": { + "target": "com.amazonaws.signin#ExpiresIn", + "traits": { + "smithy.api#documentation": "Time to expiry in seconds (maximum 900)\nPresent for both authorization code redemption and token refresh", + "smithy.api#jsonName": "expiresIn", + "smithy.api#required": {} + } + }, + "refreshToken": { + "target": "com.amazonaws.signin#RefreshToken", + "traits": { + "smithy.api#documentation": "Encrypted refresh token with cnf.jkt (SHA-256 thumbprint of presented jwk)\nAlways present in responses (required for both flows)", + "smithy.api#jsonName": "refreshToken", + "smithy.api#required": {} + } + }, + "idToken": { + "target": "com.amazonaws.signin#IdToken", + "traits": { + "smithy.api#documentation": "ID token containing user identity information\nPresent only in authorization code redemption response (grant_type=authorization_code)\nNot included in token refresh responses", + "smithy.api#jsonName": "idToken" + } + } + }, + "traits": { + "smithy.api#documentation": "Response body payload for CreateOAuth2Token operation\n\nThe response content depends on the grant_type from the request:\n- grant_type=authorization_code: Returns all fields including refresh_token and id_token\n- grant_type=refresh_token: Returns access_token, token_type, expires_in, refresh_token (no id_token)" + } + }, + "com.amazonaws.signin#DPoPProof": { + "type": "string", + "traits": { + "smithy.api#documentation": "DPoP proof JWT for demonstrating proof-of-possession\n\nDPoP (Demonstration of Proof-of-Possession) JWT header structure:\nHeader: {\"typ\": \"dpop+jwt\", \"alg\": \"ES256\", \"jwk\": {...}}\nPayload: {\"htm\": \"POST\", \"htu\": \"https://server.example.com/token\", \"iat\": timestamp, \"jti\": \"unique-id\"}\n\nKey components:\n- htm: HTTP method being used (POST)\n- htu: HTTP URI being accessed (e.g., https://us-east-1.signin.aws.amazon.com/v1/token)\n- iat: Issued at timestamp\n- jti: Unique identifier for the JWT\n\nFor token refresh: Must be generated with same private key as original auth_code redemption.\nOnly iat changes (within acceptable clock skew).", + "smithy.api#length": { + "min": 1, + "max": 4096 + } + } + }, + "com.amazonaws.signin#ExpiresIn": { + "type": "integer", + "traits": { + "smithy.api#documentation": "Time to expiry in seconds\n\nThe time to expiry in seconds, for these purposes will be at most 900 (15 minutes).", + "smithy.api#range": { + "min": 1, + "max": 900 + } + } + }, + "com.amazonaws.signin#ForbiddenError": { + "type": "structure", + "members": { + "message": { + "target": "smithy.api#String", + "traits": { + "smithy.api#documentation": "Detailed message explaining the authorization failure\nProvides information about permission or policy violations", + "smithy.api#required": {} + } + } + }, + "traits": { + "smithy.api#documentation": "Error thrown when the client is not authorized or has insufficient permissions\n\nHTTP Status Code: 403 Forbidden\n\nPossible causes:\n- Unauthorized widget or client_id not registered\n- Insufficient permissions for the requested scope\n- Client not authorized to use the specified grant_type\n- DPoP key binding validation failure (cnf.jkt mismatch)\n- Policy evaluation failure for credential scoping", + "smithy.api#error": "client", + "smithy.api#httpError": 403 + } + }, + "com.amazonaws.signin#GrantType": { + "type": "string", + "traits": { + "smithy.api#documentation": "OAuth 2.0 grant type parameter\n\nFor auth code redemption: Must be \"authorization_code\"\nFor token refresh: Must be \"refresh_token\"\n\nBased on client_id & grant_type, authn/authz is skipped for CLI endpoints.", + "smithy.api#pattern": "^(authorization_code|refresh_token)$" + } + }, + "com.amazonaws.signin#IdToken": { + "type": "string", + "traits": { + "smithy.api#documentation": "ID token containing user identity information\n\nEncoded JWT token containing user identity claims and authentication context.\nReturned only in authorization code redemption responses (grant_type=authorization_code).\nContains user identity information such as ARN and other identity claims.", + "smithy.api#length": { + "min": 1, + "max": 4096 + } + } + }, + "com.amazonaws.signin#InvalidRequestError": { + "type": "structure", + "members": { + "message": { + "target": "smithy.api#String", + "traits": { + "smithy.api#documentation": "Detailed message explaining the request validation failure\nProvides specific information about which parameter or validation failed", + "smithy.api#required": {} + } + } + }, + "traits": { + "smithy.api#documentation": "Error thrown when the request is malformed or missing required parameters\n\nHTTP Status Code: 400 Bad Request\n\nPossible causes:\n- Malformed request body (invalid form encoding)\n- Missing required parameters (client_id, grant_type, code, etc.)\n- Invalid parameter values (malformed client_id ARN, invalid grant_type)\n- Invalid DPoP proof JWT format or signature\n- PKCE code_verifier validation failure\n- Redirect URI mismatch with original authorization request", + "smithy.api#error": "client", + "smithy.api#httpError": 400 + } + }, + "com.amazonaws.signin#RedirectUri": { + "type": "string", + "traits": { + "smithy.api#documentation": "Redirect URI for OAuth 2.0 flow validation\n\nThe same redirect URI used in the authorization request. This must match exactly\nwhat was sent in the original authorization request for security validation.", + "smithy.api#length": { + "min": 1, + "max": 2048 + } + } + }, + "com.amazonaws.signin#RefreshToken": { + "type": "string", + "traits": { + "smithy.api#documentation": "Encrypted refresh token with cnf.jkt\n\nThis is the encrypted refresh token returned from auth code redemption.\nThe token content includes cnf.jkt (SHA-256 thumbprint of the presented jwk).\nUsed in subsequent token refresh requests.", + "smithy.api#length": { + "min": 1, + "max": 2048 + }, + "smithy.api#sensitive": {} + } + }, + "com.amazonaws.signin#Signin": { + "type": "service", + "version": "2023-01-01", + "operations": [ + { + "target": "com.amazonaws.signin#CreateOAuth2Token" + } + ], + "traits": { + "aws.api#service": { + "sdkId": "Signin", + "arnNamespace": "signin", + "endpointPrefix": "signin" + }, + "aws.auth#sigv4": { + "name": "signin" + }, + "aws.endpoints#standardRegionalEndpoints": { + "partitionSpecialCases": { + "aws": [ + { + "endpoint": "https://{region}.signin.aws.amazon.com" + } + ], + "aws-cn": [ + { + "endpoint": "https://{region}.signin.amazonaws.cn" + } + ], + "aws-us-gov": [ + { + "endpoint": "https://{region}.signin.amazonaws-us-gov.com" + } + ] + } + }, + "aws.protocols#restJson1": {}, + "smithy.api#auth": [ + "aws.auth#sigv4" + ], + "smithy.api#documentation": "AWS Sign-In Data Plane Service\n\nThis service implements OAuth 2.0 flows for AWS CLI authentication,\nproviding secure token exchange and refresh capabilities.", + "smithy.api#title": "AWS Sign-In Data Plane", + "smithy.rules#endpointRuleSet": { + "version": "1.0", + "parameters": { + "UseDualStack": { + "builtIn": "AWS::UseDualStack", + "required": true, + "default": false, + "documentation": "When true, use the dual-stack endpoint. If the configured endpoint does not support dual-stack, dispatching the request MAY return an error.", + "type": "boolean" + }, + "UseFIPS": { + "builtIn": "AWS::UseFIPS", + "required": true, + "default": false, + "documentation": "When true, send this request to the FIPS-compliant regional endpoint. If the configured endpoint does not have a FIPS compliant endpoint, dispatching the request will return an error.", + "type": "boolean" + }, + "Endpoint": { + "builtIn": "SDK::Endpoint", + "required": false, + "documentation": "Override the endpoint used to send this request", + "type": "string" + }, + "Region": { + "builtIn": "AWS::Region", + "required": false, + "documentation": "The AWS region used to dispatch the request.", + "type": "string" + } + }, + "rules": [ + { + "conditions": [ + { + "fn": "isSet", + "argv": [ + { + "ref": "Endpoint" + } + ] + } + ], + "rules": [ + { + "conditions": [ + { + "fn": "booleanEquals", + "argv": [ + { + "ref": "UseFIPS" + }, + true + ] + } + ], + "error": "Invalid Configuration: FIPS and custom endpoint are not supported", + "type": "error" + }, + { + "conditions": [], + "rules": [ + { + "conditions": [ + { + "fn": "booleanEquals", + "argv": [ + { + "ref": "UseDualStack" + }, + true + ] + } + ], + "error": "Invalid Configuration: Dualstack and custom endpoint are not supported", + "type": "error" + }, + { + "conditions": [], + "endpoint": { + "url": { + "ref": "Endpoint" + }, + "properties": {}, + "headers": {} + }, + "type": "endpoint" + } + ], + "type": "tree" + } + ], + "type": "tree" + }, + { + "conditions": [], + "rules": [ + { + "conditions": [ + { + "fn": "isSet", + "argv": [ + { + "ref": "Region" + } + ] + } + ], + "rules": [ + { + "conditions": [ + { + "fn": "aws.partition", + "argv": [ + { + "ref": "Region" + } + ], + "assign": "PartitionResult" + } + ], + "rules": [ + { + "conditions": [ + { + "fn": "stringEquals", + "argv": [ + { + "fn": "getAttr", + "argv": [ + { + "ref": "PartitionResult" + }, + "name" + ] + }, + "aws" + ] + }, + { + "fn": "booleanEquals", + "argv": [ + { + "ref": "UseFIPS" + }, + false + ] + }, + { + "fn": "booleanEquals", + "argv": [ + { + "ref": "UseDualStack" + }, + false + ] + } + ], + "endpoint": { + "url": "https://{Region}.signin.aws.amazon.com", + "properties": {}, + "headers": {} + }, + "type": "endpoint" + }, + { + "conditions": [ + { + "fn": "stringEquals", + "argv": [ + { + "fn": "getAttr", + "argv": [ + { + "ref": "PartitionResult" + }, + "name" + ] + }, + "aws-cn" + ] + }, + { + "fn": "booleanEquals", + "argv": [ + { + "ref": "UseFIPS" + }, + false + ] + }, + { + "fn": "booleanEquals", + "argv": [ + { + "ref": "UseDualStack" + }, + false + ] + } + ], + "endpoint": { + "url": "https://{Region}.signin.amazonaws.cn", + "properties": {}, + "headers": {} + }, + "type": "endpoint" + }, + { + "conditions": [ + { + "fn": "stringEquals", + "argv": [ + { + "fn": "getAttr", + "argv": [ + { + "ref": "PartitionResult" + }, + "name" + ] + }, + "aws-us-gov" + ] + }, + { + "fn": "booleanEquals", + "argv": [ + { + "ref": "UseFIPS" + }, + false + ] + }, + { + "fn": "booleanEquals", + "argv": [ + { + "ref": "UseDualStack" + }, + false + ] + } + ], + "endpoint": { + "url": "https://{Region}.signin.amazonaws-us-gov.com", + "properties": {}, + "headers": {} + }, + "type": "endpoint" + }, + { + "conditions": [ + { + "fn": "booleanEquals", + "argv": [ + { + "ref": "UseFIPS" + }, + true + ] + }, + { + "fn": "booleanEquals", + "argv": [ + { + "ref": "UseDualStack" + }, + true + ] + } + ], + "rules": [ + { + "conditions": [ + { + "fn": "booleanEquals", + "argv": [ + true, + { + "fn": "getAttr", + "argv": [ + { + "ref": "PartitionResult" + }, + "supportsFIPS" + ] + } + ] + }, + { + "fn": "booleanEquals", + "argv": [ + true, + { + "fn": "getAttr", + "argv": [ + { + "ref": "PartitionResult" + }, + "supportsDualStack" + ] + } + ] + } + ], + "rules": [ + { + "conditions": [], + "endpoint": { + "url": "https://signin-fips.{Region}.{PartitionResult#dualStackDnsSuffix}", + "properties": {}, + "headers": {} + }, + "type": "endpoint" + } + ], + "type": "tree" + }, + { + "conditions": [], + "error": "FIPS and DualStack are enabled, but this partition does not support one or both", + "type": "error" + } + ], + "type": "tree" + }, + { + "conditions": [ + { + "fn": "booleanEquals", + "argv": [ + { + "ref": "UseFIPS" + }, + true + ] + }, + { + "fn": "booleanEquals", + "argv": [ + { + "ref": "UseDualStack" + }, + false + ] + } + ], + "rules": [ + { + "conditions": [ + { + "fn": "booleanEquals", + "argv": [ + { + "fn": "getAttr", + "argv": [ + { + "ref": "PartitionResult" + }, + "supportsFIPS" + ] + }, + true + ] + } + ], + "rules": [ + { + "conditions": [], + "endpoint": { + "url": "https://signin-fips.{Region}.{PartitionResult#dnsSuffix}", + "properties": {}, + "headers": {} + }, + "type": "endpoint" + } + ], + "type": "tree" + }, + { + "conditions": [], + "error": "FIPS is enabled but this partition does not support FIPS", + "type": "error" + } + ], + "type": "tree" + }, + { + "conditions": [ + { + "fn": "booleanEquals", + "argv": [ + { + "ref": "UseFIPS" + }, + false + ] + }, + { + "fn": "booleanEquals", + "argv": [ + { + "ref": "UseDualStack" + }, + true + ] + } + ], + "rules": [ + { + "conditions": [ + { + "fn": "booleanEquals", + "argv": [ + true, + { + "fn": "getAttr", + "argv": [ + { + "ref": "PartitionResult" + }, + "supportsDualStack" + ] + } + ] + } + ], + "rules": [ + { + "conditions": [], + "endpoint": { + "url": "https://signin.{Region}.{PartitionResult#dualStackDnsSuffix}", + "properties": {}, + "headers": {} + }, + "type": "endpoint" + } + ], + "type": "tree" + }, + { + "conditions": [], + "error": "DualStack is enabled but this partition does not support DualStack", + "type": "error" + } + ], + "type": "tree" + }, + { + "conditions": [], + "endpoint": { + "url": "https://signin.{Region}.{PartitionResult#dnsSuffix}", + "properties": {}, + "headers": {} + }, + "type": "endpoint" + } + ], + "type": "tree" + } + ], + "type": "tree" + }, + { + "conditions": [], + "error": "Invalid Configuration: Missing Region", + "type": "error" + } + ], + "type": "tree" + } + ] + }, + "smithy.rules#endpointTests": { + "testCases": [ + { + "documentation": "For custom endpoint with region not set and fips disabled", + "expect": { + "endpoint": { + "url": "https://example.com" + } + }, + "params": { + "Endpoint": "https://example.com", + "UseFIPS": false + } + }, + { + "documentation": "For custom endpoint with fips enabled", + "expect": { + "error": "Invalid Configuration: FIPS and custom endpoint are not supported" + }, + "params": { + "Endpoint": "https://example.com", + "UseFIPS": true + } + }, + { + "documentation": "For custom endpoint with fips disabled and dualstack enabled", + "expect": { + "error": "Invalid Configuration: Dualstack and custom endpoint are not supported" + }, + "params": { + "Endpoint": "https://example.com", + "UseFIPS": false, + "UseDualStack": true + } + }, + { + "documentation": "For region us-east-1 with FIPS enabled and DualStack enabled", + "expect": { + "endpoint": { + "url": "https://signin-fips.us-east-1.api.aws" + } + }, + "params": { + "Region": "us-east-1", + "UseFIPS": true, + "UseDualStack": true + } + }, + { + "documentation": "For region us-east-1 with FIPS enabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://signin-fips.us-east-1.amazonaws.com" + } + }, + "params": { + "Region": "us-east-1", + "UseFIPS": true, + "UseDualStack": false + } + }, + { + "documentation": "For region us-east-1 with FIPS disabled and DualStack enabled", + "expect": { + "endpoint": { + "url": "https://signin.us-east-1.api.aws" + } + }, + "params": { + "Region": "us-east-1", + "UseFIPS": false, + "UseDualStack": true + } + }, + { + "documentation": "For region us-east-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://us-east-1.signin.aws.amazon.com" + } + }, + "params": { + "Region": "us-east-1", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region cn-northwest-1 with FIPS enabled and DualStack enabled", + "expect": { + "endpoint": { + "url": "https://signin-fips.cn-northwest-1.api.amazonwebservices.com.cn" + } + }, + "params": { + "Region": "cn-northwest-1", + "UseFIPS": true, + "UseDualStack": true + } + }, + { + "documentation": "For region cn-northwest-1 with FIPS enabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://signin-fips.cn-northwest-1.amazonaws.com.cn" + } + }, + "params": { + "Region": "cn-northwest-1", + "UseFIPS": true, + "UseDualStack": false + } + }, + { + "documentation": "For region cn-northwest-1 with FIPS disabled and DualStack enabled", + "expect": { + "endpoint": { + "url": "https://signin.cn-northwest-1.api.amazonwebservices.com.cn" + } + }, + "params": { + "Region": "cn-northwest-1", + "UseFIPS": false, + "UseDualStack": true + } + }, + { + "documentation": "For region cn-northwest-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://cn-northwest-1.signin.amazonaws.cn" + } + }, + "params": { + "Region": "cn-northwest-1", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region us-gov-west-1 with FIPS enabled and DualStack enabled", + "expect": { + "endpoint": { + "url": "https://signin-fips.us-gov-west-1.api.aws" + } + }, + "params": { + "Region": "us-gov-west-1", + "UseFIPS": true, + "UseDualStack": true + } + }, + { + "documentation": "For region us-gov-west-1 with FIPS enabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://signin-fips.us-gov-west-1.amazonaws.com" + } + }, + "params": { + "Region": "us-gov-west-1", + "UseFIPS": true, + "UseDualStack": false + } + }, + { + "documentation": "For region us-gov-west-1 with FIPS disabled and DualStack enabled", + "expect": { + "endpoint": { + "url": "https://signin.us-gov-west-1.api.aws" + } + }, + "params": { + "Region": "us-gov-west-1", + "UseFIPS": false, + "UseDualStack": true + } + }, + { + "documentation": "For region us-gov-west-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://us-gov-west-1.signin.amazonaws-us-gov.com" + } + }, + "params": { + "Region": "us-gov-west-1", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region us-iso-east-1 with FIPS enabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://signin-fips.us-iso-east-1.c2s.ic.gov" + } + }, + "params": { + "Region": "us-iso-east-1", + "UseFIPS": true, + "UseDualStack": false + } + }, + { + "documentation": "For region us-iso-east-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://signin.us-iso-east-1.c2s.ic.gov" + } + }, + "params": { + "Region": "us-iso-east-1", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region us-isob-east-1 with FIPS enabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://signin-fips.us-isob-east-1.sc2s.sgov.gov" + } + }, + "params": { + "Region": "us-isob-east-1", + "UseFIPS": true, + "UseDualStack": false + } + }, + { + "documentation": "For region us-isob-east-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://signin.us-isob-east-1.sc2s.sgov.gov" + } + }, + "params": { + "Region": "us-isob-east-1", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region eu-isoe-west-1 with FIPS enabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://signin-fips.eu-isoe-west-1.cloud.adc-e.uk" + } + }, + "params": { + "Region": "eu-isoe-west-1", + "UseFIPS": true, + "UseDualStack": false + } + }, + { + "documentation": "For region eu-isoe-west-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://signin.eu-isoe-west-1.cloud.adc-e.uk" + } + }, + "params": { + "Region": "eu-isoe-west-1", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region us-isof-south-1 with FIPS enabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://signin-fips.us-isof-south-1.csp.hci.ic.gov" + } + }, + "params": { + "Region": "us-isof-south-1", + "UseFIPS": true, + "UseDualStack": false + } + }, + { + "documentation": "For region us-isof-south-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://signin.us-isof-south-1.csp.hci.ic.gov" + } + }, + "params": { + "Region": "us-isof-south-1", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "For region eusc-de-east-1 with FIPS enabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://signin-fips.eusc-de-east-1.amazonaws.eu" + } + }, + "params": { + "Region": "eusc-de-east-1", + "UseFIPS": true, + "UseDualStack": false + } + }, + { + "documentation": "For region eusc-de-east-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://signin.eusc-de-east-1.amazonaws.eu" + } + }, + "params": { + "Region": "eusc-de-east-1", + "UseFIPS": false, + "UseDualStack": false + } + }, + { + "documentation": "Missing region", + "expect": { + "error": "Invalid Configuration: Missing Region" + } + } + ], + "version": "1.0" + } + } + }, + "com.amazonaws.signin#TokenType": { + "type": "string", + "traits": { + "smithy.api#documentation": "Token type parameter indicating credential usage\n\nA parameter which indicates to the client how the token must be used.\nValue is \"aws_sigv4\" (instead of typical \"Bearer\" for other OAuth systems)\nto indicate that the client must de-serialize the token and use it to generate a signature.", + "smithy.api#pattern": "^aws_sigv4$" + } + }, + "com.amazonaws.signin#TooManyRequestsError": { + "type": "structure", + "members": { + "message": { + "target": "smithy.api#String", + "traits": { + "smithy.api#documentation": "Detailed message about the rate limiting\nMay include retry-after information or rate limit details", + "smithy.api#required": {} + } + } + }, + "traits": { + "smithy.api#documentation": "Error thrown when rate limit is exceeded\n\nHTTP Status Code: 429 Too Many Requests\n\nPossible causes:\n- Too many token requests from the same client\n- Rate limiting based on client_id or IP address\n- Abuse prevention mechanisms triggered\n- Service protection against excessive token generation", + "smithy.api#error": "client", + "smithy.api#httpError": 429 + } + }, + "com.amazonaws.signin#UnauthorizedError": { + "type": "structure", + "members": { + "message": { + "target": "smithy.api#String", + "traits": { + "smithy.api#documentation": "Detailed message explaining the authentication failure\nIndicates specific authentication issue (expired token, invalid signature, etc.)", + "smithy.api#required": {} + } + } + }, + "traits": { + "smithy.api#documentation": "Error thrown when the access token is invalid or expired\n\nHTTP Status Code: 401 Unauthorized\n\nPossible causes:\n- Invalid or expired authorization code\n- Invalid or expired refresh token\n- DPoP proof JWT validation failure (invalid signature, expired, wrong key)\n- Authorization code already used (replay attack prevention)\n- Refresh token revoked or invalid", + "smithy.api#error": "client", + "smithy.api#httpError": 401 + } + } + } +} \ No newline at end of file From 0575ec73384c1983cd3517730dac9c52c315aabc Mon Sep 17 00:00:00 2001 From: Xinsong Cui Date: Thu, 6 Nov 2025 15:45:52 -0500 Subject: [PATCH 02/31] cleanup --- .../credentials/LoginCredentialsProvider.kt | 5 --- .../auth/credentials/LoginTokenProvider.kt | 44 +++---------------- .../credentials/LoginTokenProviderTest.kt | 2 - 3 files changed, 7 insertions(+), 44 deletions(-) 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 index 59eeca9533b..a71e5d5589a 100644 --- 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 @@ -25,11 +25,6 @@ public class LoginCredentialsProvider public constructor( public val platformProvider: PlatformProvider = PlatformProvider.System, private val clock: Clock = Clock.System, ) : CredentialsProvider { -// private val signinTokenProvider = //Do we need to check nullibility here -// SigninTokenProvider(signinSession, enpoint = null, httpClient = httpClient, platformProvider = platformProvider, clock = clock) -// private val loginTokenProvider = -// LoginTokenProvider(loginSession, httpClient = httpClient, platformProvider = platformProvider, clock = clock) - override suspend fun resolve(attributes: Attributes): Credentials { val logger = coroutineContext.logger() 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 index a8945f6b104..d484fbc62c7 100644 --- 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 @@ -45,17 +45,7 @@ import kotlin.time.Duration.Companion.seconds import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64.Default.UrlSafe -private const val DEFAULT_SIGNIN_TOKEN_REFRESH_BUFFER_SECONDS = 60 * 5 -//internal class PrintAllHeadersInterceptor : HttpInterceptor { -// -// override suspend fun modifyBeforeTransmit(context: ProtocolRequestInterceptorContext): HttpRequest { -// context.protocolRequest.headers.entries().forEach { (name, values) -> -// println("$name: ${values.joinToString(", ")}") -// } -// return context.protocolRequest -// } -//} - +private const val DEFAULT_SIGNIN_TOKEN_REFRESH_BUFFER_SECONDS = 60 * 5 // note: can set longer refresh window to test token refresh // HTTP interceptor that adds DPoP (Demonstration of Proof-of-Possession) headers to requests. internal class DpopInterceptor(private val dpopKeyPem: String) : HttpInterceptor { @@ -64,7 +54,6 @@ internal class DpopInterceptor(private val dpopKeyPem: String) : HttpInterceptor val dpopHeader = generateDpopProof(dpopKeyPem, endpoint) val request = context.protocolRequest.toBuilder() - println("Setting DPoP header: $dpopHeader") request.header("DPoP", dpopHeader) return request.build() @@ -74,8 +63,6 @@ internal class DpopInterceptor(private val dpopKeyPem: String) : HttpInterceptor private fun extractRequestEndpoint(request: HttpRequest): String { val url = request.url return url.toString() -// val port = if (isStandardPort(url.scheme, url.port)) "" else ":${url.port}" -// return "${url.scheme}://${url.host}$port${url.encodedPath}" } } @@ -162,9 +149,7 @@ public class LoginTokenProvider ( val cacheKey = getLoginCacheFilename(loginSessionName) val filepath = normalizePath(platformProvider.filepath("~", ".aws", "login", "cache", cacheKey), platformProvider) try { - //println("attempting to write refreshed token") val contents = serializeLoginToken(refreshed) - //println("contents: "+ contents.decodeToString()) platformProvider.writeFile(filepath, contents) } catch (ex: Exception) { coroutineContext.debug(ex) { "failed to write refreshed token back to disk at $filepath" } @@ -175,16 +160,15 @@ public class LoginTokenProvider ( private suspend fun refreshToken(oldToken: LoginToken): LoginToken { val telemetry = coroutineContext.telemetryProvider - println("attempting to refresh token") + SigninClient.fromEnvironment { httpClient = this@LoginTokenProvider.httpClient telemetryProvider = telemetry - endpointUrl = Url.parse("https://ap-northeast-1.aws-signin-testing.amazon.com") //TODO: use testing endpoint, remove this once service prod endpoint is available + endpointUrl = Url.parse("https://ap-northeast-1.aws-signin-testing.amazon.com") //TODO: testing endpoint, remove this once service prod endpoint is available interceptors += DpopInterceptor(oldToken.dpopKey) // note for implementer: this is for writing DpopProof in request header instead of sending in request - //interceptors += PrintAllHeadersInterceptor() }.use { client -> val result = client.createOAuth2Token { - dpopProof = generateDpopProof(oldToken.dpopKey!!, "https://ap-northeast-1.aws-signin-testing.amazon.com/v1/token") //TODO: remove this line once login model remove dpopproof field + dpopProof = generateDpopProof(oldToken.dpopKey!!, "https://ap-northeast-1.aws-signin-testing.amazon.com/v1/token") //TODO: remove this line once dpopProof being removed from model tokenInput { clientId = oldToken.clientId grantType = "refresh_token" @@ -216,7 +200,7 @@ internal data class ECKeyData( ) /** - * Parses a PEM-encoded EC private key and extracts the private key scalar and public key coordinates. + * Parses a PEM-encoded EC private key and extracts the private key scalar and public key (x, y) coordinates. * Supports both "EC PRIVATE KEY" and "PRIVATE KEY" PEM formats for P-256 curve keys. */ private fun parseECKeyPem(pem: String): ECKeyData { @@ -229,7 +213,7 @@ private fun parseECKeyPem(pem: String): ECKeyData { .replace("\r", "") val der = base64.decodeBase64Bytes() - println("DER hex: ${der.encodeToHex()}") + // Extract private key scalar (32 bytes at offset 7) val d = der.copyOfRange(7, 39) @@ -238,23 +222,16 @@ private fun parseECKeyPem(pem: String): ECKeyData { for (i in 40 until der.size) { if (der[i] == 0x04.toByte()) { publicKeyStart = i + 1 - println("Found 0x04 at position $i, public key starts at ${i + 1}") break } } val remainingBytes = der.size - publicKeyStart val coordLen = remainingBytes / 2 - println("Public key section: ${der.copyOfRange(publicKeyStart - 1, der.size).encodeToHex()}") val x = der.copyOfRange(publicKeyStart, publicKeyStart + coordLen).padTo32() val y = der.copyOfRange(publicKeyStart + coordLen, publicKeyStart + 2 * coordLen).padTo32() - println("Raw x (${x.size} bytes): ${x.encodeToHex()}") - println("Raw y (${y.size} bytes): ${y.encodeToHex()}") - println("d: ${d.encodeBase64String()}") - println("x: ${x.encodeBase64String()}") - println("y: ${y.encodeBase64String()}") return ECKeyData(d, x, y) } @@ -289,17 +266,15 @@ private fun generateDpopProof( writeName("kty") writeValue("EC") writeName("x") - //writeValue(xB64) writeValue(base64UrlNoPadding.encode(ecKeyData.x)) writeName("y") - //writeValue(yB64) writeValue(base64UrlNoPadding.encode(ecKeyData.y)) writeName("crv") writeValue("P-256") endObject() endObject() }.bytes - println("header: ${header?.decodeToString()}") + val payload = jsonStreamWriter().apply { beginObject() writeName("jti") @@ -312,19 +287,14 @@ private fun generateDpopProof( writeValue(System.currentTimeMillis() / 1000) endObject() }.bytes - println("payload: ${payload?.decodeToString()}") val headerEncoded = base64UrlNoPadding.encode(header!!) val payloadEncoded = base64UrlNoPadding.encode(payload!!) val message = "$headerEncoded.$payloadEncoded" - println("message: $message") val privateKeyBytes = ecKeyData.d val signature = ecdsaSecp256r1Rs(privateKeyBytes, message.encodeToByteArray()) - println("signature hex: ${signature.encodeToHex()}") - println("signature length: ${signature.size}") - return "$message.${ base64UrlNoPadding.encode(signature) }" } 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 index 96bac8e97b1..7a094a2cf02 100644 --- 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 @@ -166,7 +166,6 @@ class LoginTokenProviderTest { "[idx=$idx]: $testCase" ) assertEquals(expectedOutcome.expiresAt, credentials.expiration, "[idx=$idx]: $testCase") - println("✓ Test passed: ${testCase.name}") } is TestOutcome.CacheContents -> { @@ -180,7 +179,6 @@ class LoginTokenProviderTest { val actualJson = Json.parseToJsonElement(actualContent).jsonObject assertEquals(expectedJson, actualJson, "Cache content mismatch for $filename") } - println("✓ Cache contents verified: ${testCase.name}") } is TestOutcome.Error -> { From 098b44fa3eebeaa28883367d3e2b5415404c16df Mon Sep 17 00:00:00 2001 From: Xinsong Cui Date: Thu, 6 Nov 2025 16:34:32 -0500 Subject: [PATCH 03/31] more cleanup --- .../credentials/LoginCredentialsProvider.kt | 36 +++++++++++++++++-- .../auth/credentials/LoginTokenProvider.kt | 34 ++++++++---------- .../LoginCredentialsProviderTest.kt | 8 +++-- 3 files changed, 53 insertions(+), 25 deletions(-) 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 index a71e5d5589a..06024e5d13f 100644 --- 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 @@ -17,8 +17,40 @@ import aws.smithy.kotlin.runtime.time.Clock import aws.smithy.kotlin.runtime.util.PlatformProvider import kotlin.coroutines.coroutineContext -private const val PROVIDER_NAME = "LOGIN" - +/** + * [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 (e.g. 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, it 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. + * + * ``` + * val source = LoginCredentialsProvider( + * loginSession = "my-login-session" + * ) + * + * // Wrap the provider with a caching provider to cache the credentials until their expiration time + * val loginProvider = CachedCredentialsProvider(source) + * ``` + * 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 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 httpClient: HttpClientEngine? = null, 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 index d484fbc62c7..9239acff6ca 100644 --- 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 @@ -46,6 +46,7 @@ import kotlin.io.encoding.Base64 import kotlin.io.encoding.Base64.Default.UrlSafe private const val DEFAULT_SIGNIN_TOKEN_REFRESH_BUFFER_SECONDS = 60 * 5 // note: can set longer refresh window to test token refresh +private const val PROVIDER_NAME = "LOGIN" // HTTP interceptor that adds DPoP (Demonstration of Proof-of-Possession) headers to requests. internal class DpopInterceptor(private val dpopKeyPem: String) : HttpInterceptor { @@ -107,6 +108,7 @@ public class LoginTokenProvider ( secretAccessKey = token.secretAccessKey, sessionToken = token.sessionToken, expiration = token.expiresAt, + providerName = PROVIDER_NAME, accountId = token.accountId ) } @@ -119,15 +121,13 @@ public class LoginTokenProvider ( return token } - // token is within expiry window - if (token.canRefresh) { - return attemptRefresh(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() } - - return 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() - return token } private suspend fun attemptRefresh(oldToken: LoginToken): LoginToken { @@ -168,7 +168,7 @@ public class LoginTokenProvider ( interceptors += DpopInterceptor(oldToken.dpopKey) // note for implementer: this is for writing DpopProof in request header instead of sending in request }.use { client -> val result = client.createOAuth2Token { - dpopProof = generateDpopProof(oldToken.dpopKey!!, "https://ap-northeast-1.aws-signin-testing.amazon.com/v1/token") //TODO: remove this line once dpopProof being removed from model + dpopProof = generateDpopProof(oldToken.dpopKey, "https://ap-northeast-1.aws-signin-testing.amazon.com/v1/token") //TODO: remove this line once dpopProof being removed from model tokenInput { clientId = oldToken.clientId grantType = "refresh_token" @@ -181,10 +181,10 @@ public class LoginTokenProvider ( return try { 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 + secretAccessKey = result.tokenOutput.accessToken.secretAccessKey, + sessionToken = result.tokenOutput.accessToken.sessionToken, + expiresAt = clock.now() + result.tokenOutput.expiresIn.seconds, + refreshToken = result.tokenOutput.refreshToken ) } catch (e: Exception) { throw InvalidLoginTokenException("Failed to parse token response", e) @@ -322,16 +322,10 @@ internal data class LoginToken( val expiresAt: Instant, val refreshToken: String, val idToken: String? = null, - val clientId: String?, + val clientId: String, val dpopKey: String, ) -/** - * Test if a token has the components to allow it to be refreshed for a new one - */ -private val LoginToken.canRefresh: Boolean - get() = clientId != null && dpopKey != null && refreshToken != null - internal fun deserializeLoginToken(json: ByteArray): LoginToken { val lexer = jsonStreamReader(json) 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 index 8aa314a620c..115847d6224 100644 --- 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 @@ -136,12 +136,14 @@ class LoginCredentialsProviderTest { val actual = provider.resolve() val expected = credentials( - "AKID", - "secret", - "session-token", + accessKeyId = "AKID", + secretAccessKey = "secret", + sessionToken = "session-token", expiration = expectedExpiration, + providerName = "LOGIN", accountId = "123456789", ).withBusinessMetric(AwsBusinessMetric.Credentials.CREDENTIALS_LOGIN) + assertEquals(expected, actual) } } From 4be51015b964f03459fecd7aadce4ca114bf58a7 Mon Sep 17 00:00:00 2001 From: Xinsong Cui Date: Thu, 6 Nov 2025 16:41:20 -0500 Subject: [PATCH 04/31] lint --- aws-runtime/aws-config/build.gradle.kts | 2 +- .../credentials/LoginCredentialsProvider.kt | 1 - .../auth/credentials/LoginTokenProvider.kt | 34 ++++++++----------- .../auth/credentials/profile/LeafProvider.kt | 2 +- .../LoginCredentialsProviderTest.kt | 1 - .../credentials/LoginTokenProviderTest.kt | 29 +++++++++------- .../AwsBusinessMetricsUtils.kt | 2 +- 7 files changed, 34 insertions(+), 37 deletions(-) diff --git a/aws-runtime/aws-config/build.gradle.kts b/aws-runtime/aws-config/build.gradle.kts index b1da7cae900..4c53a12c4ed 100644 --- a/aws-runtime/aws-config/build.gradle.kts +++ b/aws-runtime/aws-config/build.gradle.kts @@ -188,7 +188,7 @@ smithyBuild { """, ) } - //Note: I removed the smoke test section in model to make build pass + // Note: I removed the smoke test section in model to make build pass create("signin-credentials-provider") { imports = listOf( awsModelFile("sign-in.json"), 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 index 06024e5d13f..cf4d07fa8e9 100644 --- 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 @@ -7,7 +7,6 @@ package aws.sdk.kotlin.runtime.auth.credentials 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.Credentials import aws.smithy.kotlin.runtime.auth.awscredentials.CredentialsProvider import aws.smithy.kotlin.runtime.collections.Attributes 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 index 9239acff6ca..1d610bfdc43 100644 --- 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 @@ -38,12 +38,11 @@ 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 aws.smithy.kotlin.runtime.text.encoding.encodeBase64String import kotlin.coroutines.coroutineContext -import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds 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 // note: can set longer refresh window to test token refresh private const val PROVIDER_NAME = "LOGIN" @@ -89,13 +88,13 @@ internal class DpopInterceptor(private val dpopKeyPem: String) : HttpInterceptor * @param platformProvider the platform provider to use * @param clock the source of time for the provider */ -public class LoginTokenProvider ( +public class LoginTokenProvider( public val loginSessionName: String, public val refreshBufferWindow: Duration = DEFAULT_SIGNIN_TOKEN_REFRESH_BUFFER_SECONDS.seconds, public val httpClient: HttpClientEngine? = null, public val platformProvider: PlatformProvider = PlatformProvider.System, private val clock: Clock = Clock.System, -): CredentialsProvider { +) : CredentialsProvider { // debounce concurrent requests for a token private val sfg = SingleFlightGroup() @@ -109,7 +108,7 @@ public class LoginTokenProvider ( sessionToken = token.sessionToken, expiration = token.expiresAt, providerName = PROVIDER_NAME, - accountId = token.accountId + accountId = token.accountId, ) } @@ -164,11 +163,11 @@ public class LoginTokenProvider ( SigninClient.fromEnvironment { httpClient = this@LoginTokenProvider.httpClient telemetryProvider = telemetry - endpointUrl = Url.parse("https://ap-northeast-1.aws-signin-testing.amazon.com") //TODO: testing endpoint, remove this once service prod endpoint is available + endpointUrl = Url.parse("https://ap-northeast-1.aws-signin-testing.amazon.com") // TODO: testing endpoint, remove this once service prod endpoint is available interceptors += DpopInterceptor(oldToken.dpopKey) // note for implementer: this is for writing DpopProof in request header instead of sending in request }.use { client -> val result = client.createOAuth2Token { - dpopProof = generateDpopProof(oldToken.dpopKey, "https://ap-northeast-1.aws-signin-testing.amazon.com/v1/token") //TODO: remove this line once dpopProof being removed from model + dpopProof = generateDpopProof(oldToken.dpopKey, "https://ap-northeast-1.aws-signin-testing.amazon.com/v1/token") // TODO: remove this line once dpopProof being removed from model tokenInput { clientId = oldToken.clientId grantType = "refresh_token" @@ -176,7 +175,7 @@ public class LoginTokenProvider ( } } - //TODO: use model provided exception: + // TODO: use model provided exception: // If the CreateOAuth2Token call returns a TBD error with the TBD member set to TBD, then the SDK MUST return an error with TBD wording. return try { oldToken.copy( @@ -184,7 +183,7 @@ public class LoginTokenProvider ( secretAccessKey = result.tokenOutput.accessToken.secretAccessKey, sessionToken = result.tokenOutput.accessToken.sessionToken, expiresAt = clock.now() + result.tokenOutput.expiresIn.seconds, - refreshToken = result.tokenOutput.refreshToken + refreshToken = result.tokenOutput.refreshToken, ) } catch (e: Exception) { throw InvalidLoginTokenException("Failed to parse token response", e) @@ -194,9 +193,9 @@ public class LoginTokenProvider ( } internal data class ECKeyData( - val d: ByteArray, // private key scalar - val x: ByteArray, // public key x coordinate - val y: ByteArray // public key y coordinate + val d: ByteArray, // private key scalar + val x: ByteArray, // public key x coordinate + val y: ByteArray, // public key y coordinate ) /** @@ -235,13 +234,8 @@ private fun parseECKeyPem(pem: String): ECKeyData { return ECKeyData(d, x, y) } -private fun ByteArray.padTo32(): ByteArray { - return if (size >= 32) { - takeLast(32).toByteArray() - } else { - ByteArray(32 - size) + this - } -} +private fun ByteArray.padTo32(): ByteArray = + if (size >= 32) takeLast(32).toByteArray() else ByteArray(32 - size) + this /** * Generates a DPoP (Demonstration of Proof-of-Possession) JWT proof for OAuth 2.0 requests. 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 4ec5df60d59..68fd3f82bce 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 @@ -101,7 +101,7 @@ internal sealed class LeafProvider { * ``` */ data class LoginSession( - val loginSessionName: String + val loginSessionName: String, ) : LeafProvider() /** 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 index 115847d6224..4a79c734ba7 100644 --- 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 @@ -12,7 +12,6 @@ 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.HttpTestConnectionBuilder import aws.smithy.kotlin.runtime.httptest.TestConnection import aws.smithy.kotlin.runtime.httptest.buildTestConnection import aws.smithy.kotlin.runtime.time.Instant 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 index 7a094a2cf02..4a314473d80 100644 --- 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 @@ -1,3 +1,8 @@ +/* + * 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 @@ -38,7 +43,7 @@ class LoginTokenProviderTest { val configContents: String, val cacheContents: Map, val mockApiCalls: JsonArray?, - val outcomes: List + val outcomes: List, ) { companion object { fun fromJson(json: JsonObject): LoginTestCase { @@ -57,10 +62,10 @@ class LoginTokenProviderTest { secretAccessKey = outcomeObj["secretAccessKey"]!!.jsonPrimitive.content, sessionToken = outcomeObj["sessionToken"]!!.jsonPrimitive.content, accountId = outcomeObj["accountId"]!!.jsonPrimitive.content, - expiresAt = Instant.fromIso8601(outcomeObj["expiresAt"]!!.jsonPrimitive.content) + expiresAt = Instant.fromIso8601(outcomeObj["expiresAt"]!!.jsonPrimitive.content), ) "cacheContents" -> TestOutcome.CacheContents( - cacheContents = outcomeObj.filterKeys { it != "result" }.mapValues { it.value.toString() } + cacheContents = outcomeObj.filterKeys { it != "result" }.mapValues { it.value.toString() }, ) else -> TestOutcome.Error } @@ -76,11 +81,11 @@ class LoginTokenProviderTest { val secretAccessKey: String, val sessionToken: String, val accountId: String, - val expiresAt: Instant + val expiresAt: Instant, ) : TestOutcome() data class CacheContents( - val cacheContents: Map + val cacheContents: Map, ) : TestOutcome() object Error : TestOutcome() @@ -108,7 +113,7 @@ class LoginTokenProviderTest { val testPlatform = TestPlatformProvider( env = mapOf("HOME" to "/home"), - fs = fs + fs = fs, ) val testClock = ManualClock(Instant.fromIso8601("2025-11-19T00:00:00Z")) @@ -128,8 +133,8 @@ class LoginTokenProviderTest { HttpResponse( statusCode, Headers.Empty, - HttpBody.fromBytes(body) - ) + HttpBody.fromBytes(body), + ), ) } else { expect(HttpResponse(statusCode, Headers.Empty, HttpBody.Empty)) @@ -145,7 +150,7 @@ class LoginTokenProviderTest { refreshBufferWindow = 0.seconds, httpClient = httpClient, platformProvider = testPlatform, - clock = testClock + clock = testClock, ) testCase.outcomes.forEach { expectedOutcome -> @@ -157,13 +162,13 @@ class LoginTokenProviderTest { assertEquals( expectedOutcome.secretAccessKey, credentials.secretAccessKey, - "[idx=$idx]: $testCase" + "[idx=$idx]: $testCase", ) assertEquals(expectedOutcome.sessionToken, credentials.sessionToken, "[idx=$idx]: $testCase") assertEquals( expectedOutcome.accountId, credentials.attributes.getOrNull(AwsClientOption.AccountId), - "[idx=$idx]: $testCase" + "[idx=$idx]: $testCase", ) assertEquals(expectedOutcome.expiresAt, credentials.expiration, "[idx=$idx]: $testCase") } @@ -427,4 +432,4 @@ private const val LOGIN_TOKEN_PROVIDER_TEST_SUITE = """ ] } ] -""" \ No newline at end of file +""" 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 1e0d04937d7..e30cb132fe9 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 @@ -85,7 +85,7 @@ public enum class AwsBusinessMetric(public override val identifier: String) : Bu CREDENTIALS_HTTP("z"), CREDENTIALS_IMDS("0"), CREDENTIALS_PROFILE_LOGIN("TBD"), - CREDENTIALS_LOGIN("TBD") + CREDENTIALS_LOGIN("TBD"), } override fun toString(): String = identifier From efe5eb59f7e4accea6f72c6d7b1a617ee6bebd9c Mon Sep 17 00:00:00 2001 From: Xinsong Cui Date: Thu, 6 Nov 2025 17:56:55 -0500 Subject: [PATCH 05/31] error message check --- .../auth/credentials/LoginTokenProvider.kt | 4 +- .../credentials/LoginTokenProviderTest.kt | 43 ++++++++++++------- 2 files changed, 30 insertions(+), 17 deletions(-) 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 index 1d610bfdc43..28ed89f062f 100644 --- 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 @@ -333,6 +333,7 @@ internal fun deserializeLoginToken(json: ByteArray): LoginToken { var idToken: String? = null var clientId: String? = null var dpopKey: String? = null + var hasAccessToken = false try { lexer.nextTokenOf() @@ -341,6 +342,7 @@ internal fun deserializeLoginToken(json: ByteArray): LoginToken { is JsonToken.EndObject -> break@loop is JsonToken.Name -> when (token.value) { "accessToken" -> { + hasAccessToken = true lexer.nextTokenOf() while (true) { when (val nestedToken = lexer.nextToken()) { @@ -370,7 +372,7 @@ internal fun deserializeLoginToken(json: ByteArray): LoginToken { } 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`") 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 index 4a314473d80..a6c7137ce5c 100644 --- 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 @@ -57,7 +57,7 @@ class LoginTokenProviderTest { val outcomeObj = outcome.jsonObject val result = outcomeObj["result"]!!.jsonPrimitive.content when (result) { - "credentials" -> TestOutcome.Success( + "credentials" -> TestOutcome.Credentials( accessKeyId = outcomeObj["accessKeyId"]!!.jsonPrimitive.content, secretAccessKey = outcomeObj["secretAccessKey"]!!.jsonPrimitive.content, sessionToken = outcomeObj["sessionToken"]!!.jsonPrimitive.content, @@ -67,7 +67,10 @@ class LoginTokenProviderTest { "cacheContents" -> TestOutcome.CacheContents( cacheContents = outcomeObj.filterKeys { it != "result" }.mapValues { it.value.toString() }, ) - else -> TestOutcome.Error + "error" -> TestOutcome.Error( + message = outcomeObj["message"]!!.jsonPrimitive.content + ) + else -> error("Unknown result type: $result") } } return LoginTestCase(name, configContents, cacheContents, mockApiCalls, outcomes) @@ -76,7 +79,7 @@ class LoginTokenProviderTest { } private sealed class TestOutcome { - data class Success( + data class Credentials( val accessKeyId: String, val secretAccessKey: String, val sessionToken: String, @@ -88,7 +91,7 @@ class LoginTokenProviderTest { val cacheContents: Map, ) : TestOutcome() - object Error : TestOutcome() + data class Error(val message: String) : TestOutcome() } @Test @@ -118,12 +121,9 @@ class LoginTokenProviderTest { val testClock = ManualClock(Instant.fromIso8601("2025-11-19T00:00:00Z")) - val originalTestCase = testList[idx].jsonObject - val mockApiCalls = originalTestCase["mockApiCalls"]?.jsonArray - val httpClient = if (testCase.mockApiCalls != null) { buildTestConnection { - mockApiCalls!!.forEach { mockCall -> + testCase.mockApiCalls.forEach { mockCall -> val responseCode = mockCall.jsonObject["responseCode"]?.jsonPrimitive?.int ?: 200 val statusCode = HttpStatusCode.fromValue(responseCode) if (responseCode == 200) { @@ -155,7 +155,7 @@ class LoginTokenProviderTest { testCase.outcomes.forEach { expectedOutcome -> when (expectedOutcome) { - is TestOutcome.Success -> { + 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") @@ -187,9 +187,14 @@ class LoginTokenProviderTest { } is TestOutcome.Error -> { - assertFails("[idx=$idx]: $testCase") { + 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}" + ) } } } @@ -236,7 +241,8 @@ private const val LOGIN_TOKEN_PROVIDER_TEST_SUITE = """ }, "outcomes": [ { - "result": "error" + "result": "error", + "message": "Invalid or missing login session cache. Run `aws login` to initiate a new session" } ] }, @@ -253,7 +259,8 @@ private const val LOGIN_TOKEN_PROVIDER_TEST_SUITE = """ }, "outcomes": [ { - "result": "error" + "result": "error", + "message": "missing `accessToken`" } ] }, @@ -276,7 +283,8 @@ private const val LOGIN_TOKEN_PROVIDER_TEST_SUITE = """ }, "outcomes": [ { - "result": "error" + "result": "error", + "message": "missing `refreshToken`" } ] }, @@ -299,7 +307,8 @@ private const val LOGIN_TOKEN_PROVIDER_TEST_SUITE = """ }, "outcomes": [ { - "result": "error" + "result": "error", + "message": "missing `clientId`" } ] }, @@ -322,7 +331,8 @@ private const val LOGIN_TOKEN_PROVIDER_TEST_SUITE = """ }, "outcomes": [ { - "result": "error" + "result": "error", + "message": "missing `dpopKey`" } ] }, @@ -427,7 +437,8 @@ private const val LOGIN_TOKEN_PROVIDER_TEST_SUITE = """ ], "outcomes": [ { - "result": "error" + "result": "error", + "message": "Login token for login-session: arn:aws:sts::012345678910:assumed-role/Admin/admin is expired" } ] } From b37b6629813c005ce7a5b489dc2c3f3d6b6d989a Mon Sep 17 00:00:00 2001 From: Xinsong Cui Date: Fri, 7 Nov 2025 11:11:29 -0500 Subject: [PATCH 06/31] add comment --- .../sdk/kotlin/runtime/auth/credentials/LoginTokenProvider.kt | 2 +- .../kotlin/runtime/auth/credentials/LoginTokenProviderTest.kt | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) 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 index 28ed89f062f..482d124e7fa 100644 --- 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 @@ -44,7 +44,7 @@ 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 // note: can set longer refresh window to test token refresh +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. 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 index a6c7137ce5c..59478e5da1b 100644 --- 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 @@ -202,6 +202,8 @@ class LoginTokenProviderTest { } } +// 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 = """ [ From 77296936306dd85c3c7b218ef8284954d352dbc5 Mon Sep 17 00:00:00 2001 From: Xinsong Cui Date: Fri, 7 Nov 2025 13:51:53 -0500 Subject: [PATCH 07/31] more cleanup --- .../sdk/kotlin/runtime/auth/credentials/LoginTokenProvider.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 482d124e7fa..283deade04f 100644 --- 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 @@ -203,6 +203,7 @@ internal data class ECKeyData( * Supports both "EC PRIVATE KEY" and "PRIVATE KEY" PEM formats for P-256 curve keys. */ private fun parseECKeyPem(pem: String): ECKeyData { + // Note: adding PKCS#8 (BEGIN PRIVATE KEY) to support modeled test case, can be removed once model test case is updated val base64 = pem.replace("-----BEGIN EC PRIVATE KEY-----", "") .replace("-----END EC PRIVATE KEY-----", "") .replace("-----BEGIN PRIVATE KEY-----", "") @@ -276,7 +277,7 @@ private fun generateDpopProof( writeName("htm") writeValue("POST") writeName("htu") - writeValue(endpoint) // hardcoded test endpoint, TODO: change it + writeValue(endpoint) writeName("iat") writeValue(System.currentTimeMillis() / 1000) endObject() From 50cbdb003b96210a08dd44b5e2311d0d662500e2 Mon Sep 17 00:00:00 2001 From: Xinsong Cui Date: Fri, 7 Nov 2025 15:07:16 -0500 Subject: [PATCH 08/31] fix --- .../sdk/kotlin/runtime/auth/credentials/LoginTokenProvider.kt | 2 +- .../kotlin/runtime/auth/credentials/LoginTokenProviderTest.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index 283deade04f..9412ce0c973 100644 --- 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 @@ -279,7 +279,7 @@ private fun generateDpopProof( writeName("htu") writeValue(endpoint) writeName("iat") - writeValue(System.currentTimeMillis() / 1000) + writeValue(Clock.System.now().epochSeconds) endObject() }.bytes 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 index 59478e5da1b..7681b52af7a 100644 --- 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 @@ -95,7 +95,7 @@ class LoginTokenProviderTest { } @Test - fun testLoginTokenCacheBehavior() = runTest { + fun testLoginTokenBehavior() = runTest { val testList = Json.parseToJsonElement(LOGIN_TOKEN_PROVIDER_TEST_SUITE).jsonArray testList.map { testCase -> runCatching { From bc44a9e258241d986b3060331dc050f82a2cf846 Mon Sep 17 00:00:00 2001 From: Xinsong Cui Date: Fri, 7 Nov 2025 15:13:41 -0500 Subject: [PATCH 09/31] changelog --- .changes/930c7904-1735-466b-9acc-c46e960c26c9.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/930c7904-1735-466b-9acc-c46e960c26c9.json diff --git a/.changes/930c7904-1735-466b-9acc-c46e960c26c9.json b/.changes/930c7904-1735-466b-9acc-c46e960c26c9.json new file mode 100644 index 00000000000..5bfa081094b --- /dev/null +++ b/.changes/930c7904-1735-466b-9acc-c46e960c26c9.json @@ -0,0 +1,5 @@ +{ + "id": "930c7904-1735-466b-9acc-c46e960c26c9", + "type": "feature", + "description": "added a new credentials provider for AWS Login token authentication" +} \ No newline at end of file From 2d397931495de2258842461a3bea7acea520adb9 Mon Sep 17 00:00:00 2001 From: Xinsong Cui Date: Fri, 7 Nov 2025 15:13:50 -0500 Subject: [PATCH 10/31] lint --- .../kotlin/runtime/auth/credentials/LoginTokenProviderTest.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 7681b52af7a..785c8ae5d8f 100644 --- 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 @@ -68,7 +68,7 @@ class LoginTokenProviderTest { cacheContents = outcomeObj.filterKeys { it != "result" }.mapValues { it.value.toString() }, ) "error" -> TestOutcome.Error( - message = outcomeObj["message"]!!.jsonPrimitive.content + message = outcomeObj["message"]!!.jsonPrimitive.content, ) else -> error("Unknown result type: $result") } @@ -193,7 +193,7 @@ class LoginTokenProviderTest { assertEquals( exception.message?.contains(expectedOutcome.message), true, - "[idx=$idx]: Expected error message to contain '${expectedOutcome.message}', but got: ${exception.message}" + "[idx=$idx]: Expected error message to contain '${expectedOutcome.message}', but got: ${exception.message}", ) } } From 1044b128654a8b71c3aa84aff60a6e679fb3ce06 Mon Sep 17 00:00:00 2001 From: Xinsong Cui Date: Fri, 7 Nov 2025 15:40:14 -0500 Subject: [PATCH 11/31] test workflow fix --- .github/workflows/api-compat-verification.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/api-compat-verification.yml b/.github/workflows/api-compat-verification.yml index 5d7de9de0c4..4d878ae1d33 100644 --- a/.github/workflows/api-compat-verification.yml +++ b/.github/workflows/api-compat-verification.yml @@ -14,6 +14,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 + with: + token: ${{ secrets.CI_USER_PAT }} + fetch-depth: 0 - name: Check for API compatibility if: ${{ !contains(github.event.pull_request.labels.*.name, 'acknowledge-api-break') }} run: | From 98d80514e9d6e4ddae25377b6af3346ef409d200 Mon Sep 17 00:00:00 2001 From: Xinsong Cui Date: Fri, 7 Nov 2025 16:05:37 -0500 Subject: [PATCH 12/31] test workflow fix --- .github/actions/setup-build/action.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/actions/setup-build/action.yml b/.github/actions/setup-build/action.yml index 62353d3bb88..de75b836f00 100644 --- a/.github/actions/setup-build/action.yml +++ b/.github/actions/setup-build/action.yml @@ -13,6 +13,8 @@ runs: ref: '0.4.2' sparse-checkout: | .github + token: ${{ secrets.CI_USER_PAT }} + fetch-depth: 0 - name: Checkout smithy-kotlin uses: ./aws-kotlin-repo-tools/.github/actions/checkout-head with: From 15ea7cb75194afe58394f2e73c838f628421b2c8 Mon Sep 17 00:00:00 2001 From: Xinsong Cui Date: Fri, 7 Nov 2025 16:07:23 -0500 Subject: [PATCH 13/31] test workflow fix --- .github/actions/setup-build/action.yml | 2 -- .github/workflows/service-ci.yml | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/setup-build/action.yml b/.github/actions/setup-build/action.yml index de75b836f00..62353d3bb88 100644 --- a/.github/actions/setup-build/action.yml +++ b/.github/actions/setup-build/action.yml @@ -13,8 +13,6 @@ runs: ref: '0.4.2' sparse-checkout: | .github - token: ${{ secrets.CI_USER_PAT }} - fetch-depth: 0 - name: Checkout smithy-kotlin uses: ./aws-kotlin-repo-tools/.github/actions/checkout-head with: diff --git a/.github/workflows/service-ci.yml b/.github/workflows/service-ci.yml index 163b1209fd1..6a18ec29948 100644 --- a/.github/workflows/service-ci.yml +++ b/.github/workflows/service-ci.yml @@ -49,4 +49,6 @@ concurrency: permissions: id-token: write contents: read + actions: read + repository-projects: read From fb287a0ea51d38306c5d0b31dcd16bf3c1bc273c Mon Sep 17 00:00:00 2001 From: Xinsong Cui Date: Fri, 7 Nov 2025 16:14:13 -0500 Subject: [PATCH 14/31] test workflow fix --- .github/workflows/service-ci.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/service-ci.yml b/.github/workflows/service-ci.yml index 6a18ec29948..44f05988163 100644 --- a/.github/workflows/service-ci.yml +++ b/.github/workflows/service-ci.yml @@ -13,7 +13,11 @@ jobs: with: role-to-assume: ${{ secrets.CI_AWS_ROLE_ARN }} aws-region: us-west-2 - + - name: Checkout ools + uses: actions/checkout@v2 + with: + repository: awslabs/aws-kotlin-repo-tools + token: ${{ secrets.CI_USER_PAT }} - name: Build and upload metrics id: build env: @@ -49,6 +53,3 @@ concurrency: permissions: id-token: write contents: read - actions: read - repository-projects: read - From 9293e77237c9e60dd906b70e122dd1383b775658 Mon Sep 17 00:00:00 2001 From: 0marperez Date: Mon, 10 Nov 2025 11:51:39 -0500 Subject: [PATCH 15/31] misc: increase test timeout --- .../kotlin/runtime/auth/credentials/LoginTokenProviderTest.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 59478e5da1b..e3a63624394 100644 --- 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 @@ -33,6 +33,7 @@ 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 @@ -95,7 +96,7 @@ class LoginTokenProviderTest { } @Test - fun testLoginTokenCacheBehavior() = runTest { + fun testLoginTokenCacheBehavior() = runTest(timeout = 2.minutes) { val testList = Json.parseToJsonElement(LOGIN_TOKEN_PROVIDER_TEST_SUITE).jsonArray testList.map { testCase -> runCatching { From aa74dad8e51ee7563496444d9a4dd7dc72d09684 Mon Sep 17 00:00:00 2001 From: 0marperez Date: Mon, 10 Nov 2025 12:24:56 -0500 Subject: [PATCH 16/31] attempt to fix some CI --- .github/workflows/minor-version-bump.yml | 2 +- .github/workflows/service-ci.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 44f05988163..208652893a2 100644 --- a/.github/workflows/service-ci.yml +++ b/.github/workflows/service-ci.yml @@ -16,7 +16,7 @@ jobs: - name: Checkout ools uses: actions/checkout@v2 with: - repository: awslabs/aws-kotlin-repo-tools + repository: aws/aws-kotlin-repo-tools token: ${{ secrets.CI_USER_PAT }} - name: Build and upload metrics id: build @@ -32,7 +32,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' From 3986d7a0b54e38a3aad79612a2c0dd2d3f684d98 Mon Sep 17 00:00:00 2001 From: 0marperez Date: Mon, 10 Nov 2025 12:29:07 -0500 Subject: [PATCH 17/31] rerun CI (Only 4 workflows being detected) From 7473faf7a3a41e48989f7cafc373672b5b97eb59 Mon Sep 17 00:00:00 2001 From: 0marperez Date: Mon, 10 Nov 2025 17:34:05 -0500 Subject: [PATCH 18/31] review and cleanup --- .../930c7904-1735-466b-9acc-c46e960c26c9.json | 2 +- .github/workflows/service-ci.yml | 6 +----- .../auth/credentials/LoginCredentialsProvider.kt | 12 ++++++++---- .../auth/credentials/LoginTokenProvider.kt | 16 +++++----------- .../auth/credentials/profile/LeafProvider.kt | 2 +- .../auth/credentials/profile/ProfileChain.kt | 4 ++-- gradle/libs.versions.toml | 4 ++-- 7 files changed, 20 insertions(+), 26 deletions(-) diff --git a/.changes/930c7904-1735-466b-9acc-c46e960c26c9.json b/.changes/930c7904-1735-466b-9acc-c46e960c26c9.json index 5bfa081094b..eef26271835 100644 --- a/.changes/930c7904-1735-466b-9acc-c46e960c26c9.json +++ b/.changes/930c7904-1735-466b-9acc-c46e960c26c9.json @@ -1,5 +1,5 @@ { "id": "930c7904-1735-466b-9acc-c46e960c26c9", "type": "feature", - "description": "added a new credentials provider for AWS Login token authentication" + "description": "Adds a new credentials provider for AWS Login token authentication" } \ No newline at end of file diff --git a/.github/workflows/service-ci.yml b/.github/workflows/service-ci.yml index 208652893a2..2b13f2d200f 100644 --- a/.github/workflows/service-ci.yml +++ b/.github/workflows/service-ci.yml @@ -13,11 +13,7 @@ jobs: with: role-to-assume: ${{ secrets.CI_AWS_ROLE_ARN }} aws-region: us-west-2 - - name: Checkout ools - uses: actions/checkout@v2 - with: - repository: aws/aws-kotlin-repo-tools - token: ${{ secrets.CI_USER_PAT }} + - name: Build and upload metrics id: build env: 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 index cf4d07fa8e9..ca851af55de 100644 --- 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 @@ -20,11 +20,10 @@ 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 (e.g. using the AWS CLI `aws login`). The provider + * 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, it is expired, or the file is malformed an exception will be thrown. - * + * If a cached token is not found, is expired, or the file is malformed an exception will be thrown. * * **Instantiating AWS Login provider directly** * @@ -60,7 +59,12 @@ public class LoginCredentialsProvider public constructor( val logger = coroutineContext.logger() val loginTokenProvider = - LoginTokenProvider(loginSession, httpClient = httpClient, platformProvider = platformProvider, clock = clock) + LoginTokenProvider( + loginSession, + httpClient = httpClient, + platformProvider = platformProvider, + clock = clock, + ) logger.trace { "Attempting to load token using token provider for login-session: `$loginSession`" } val creds = loginTokenProvider.resolve(attributes) 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 index 9412ce0c973..9667c98635c 100644 --- 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 @@ -50,7 +50,7 @@ private const val PROVIDER_NAME = "LOGIN" // HTTP interceptor that adds DPoP (Demonstration of Proof-of-Possession) headers to requests. internal class DpopInterceptor(private val dpopKeyPem: String) : HttpInterceptor { override suspend fun modifyBeforeTransmit(context: ProtocolRequestInterceptorContext): HttpRequest { - val endpoint = extractRequestEndpoint(context.protocolRequest) + val endpoint = context.protocolRequest.url.toString() val dpopHeader = generateDpopProof(dpopKeyPem, endpoint) val request = context.protocolRequest.toBuilder() @@ -58,12 +58,6 @@ internal class DpopInterceptor(private val dpopKeyPem: String) : HttpInterceptor request.header("DPoP", dpopHeader) return request.build() } - - // extracts the full request endpoint URL for use in DPoP proof generation. - private fun extractRequestEndpoint(request: HttpRequest): String { - val url = request.url - return url.toString() - } } /** @@ -74,7 +68,6 @@ internal class DpopInterceptor(private val dpopKeyPem: String) : HttpInterceptor * 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. - * See [Configure AWS Login](doc link TBD) * * This provider will attempt to refresh the cached login token periodically if needed when [resolve] is * called and a refresh token is available. @@ -122,7 +115,7 @@ public class LoginTokenProvider( return try { attemptRefresh(token) - } catch (e: Exception) { + } catch (_: 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() @@ -146,9 +139,10 @@ public class LoginTokenProvider( private suspend fun writeToken(refreshed: LoginToken) { val cacheKey = getLoginCacheFilename(loginSessionName) - val filepath = normalizePath(platformProvider.filepath("~", ".aws", "login", "cache", cacheKey), platformProvider) + val directory = platformProvider.getenv("AWS_LOGIN_IN_CACHE_DIRECTORY") ?: platformProvider.filepath("~", ".aws", "login", "cache") + val filepath = normalizePath(platformProvider.filepath(directory, cacheKey), platformProvider) + val contents = serializeLoginToken(refreshed) try { - val contents = serializeLoginToken(refreshed) platformProvider.writeFile(filepath, contents) } catch (ex: Exception) { coroutineContext.debug(ex) { "failed to write refreshed token back to disk at $filepath" } 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 68fd3f82bce..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 @@ -92,7 +92,7 @@ internal sealed class LeafProvider { ) : LeafProvider() /** - * A provider that uses for AWS login + * A provider that uses AWS login * * Example * ```ini 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 c2498c709b5..ee45438bab0 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 @@ -274,7 +274,7 @@ private fun AwsProfile.ssoSessionCreds(config: AwsSharedConfig): LeafProviderRes * 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(config: AwsSharedConfig): LeafProviderResult? { +private fun AwsProfile.loginSessionCreds(): LeafProviderResult? { val sessionName = getOrNull(LOGIN_SESSION) ?: return null return LeafProviderResult.Ok(LeafProvider.LoginSession(sessionName)) } @@ -359,7 +359,7 @@ private fun AwsProfile.leafProvider(config: AwsSharedConfig): LeafProvider { return webIdentityTokenCreds() .orElse { ssoSessionCreds(config) } .orElse(::legacySsoCreds) - .orElse { loginSessionCreds(config) } + .orElse { loginSessionCreds() } .unwrapOrElse(::processCreds) .unwrap() } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e66ada3c729..6a0cb8aa498 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,8 +12,8 @@ atomicfu-version = "0.29.0" binary-compatibility-validator-version = "0.18.0" # smithy-kotlin codegen and runtime are versioned separately -smithy-kotlin-runtime-version = "1.5.19" -smithy-kotlin-codegen-version = "0.35.19" +smithy-kotlin-runtime-version = "1.5.20" +smithy-kotlin-codegen-version = "0.35.20" # codegen smithy-version = "1.62.0" From ffabd1370897e021ea2b6176da099f66aaf7e58c Mon Sep 17 00:00:00 2001 From: 0marperez Date: Mon, 10 Nov 2025 19:22:07 -0500 Subject: [PATCH 19/31] changes from SEP (remove dpop uses from model, updated model, updated tests, updated certain error messages) --- aws-runtime/aws-config/api/aws-config.api | 57 ++++ aws-runtime/aws-config/build.gradle.kts | 3 +- .../auth/credentials/LoginTokenProvider.kt | 74 +++-- .../credentials/LoginTokenProviderTest.kt | 16 +- codegen/sdk/aws-models/sign-in.json | 254 ++++++++++-------- 5 files changed, 269 insertions(+), 135 deletions(-) diff --git a/aws-runtime/aws-config/api/aws-config.api b/aws-runtime/aws-config/api/aws-config.api index 70a6547057d..c66a69067bc 100644 --- a/aws-runtime/aws-config/api/aws-config.api +++ b/aws-runtime/aws-config/api/aws-config.api @@ -261,6 +261,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 4c53a12c4ed..60abef0e87e 100644 --- a/aws-runtime/aws-config/build.gradle.kts +++ b/aws-runtime/aws-config/build.gradle.kts @@ -188,7 +188,8 @@ smithyBuild { """, ) } - // Note: I removed the smoke test section in model to make build pass + + // FIXME: Shape from smoke tests fails projection: aws.test#AwsVendorParams create("signin-credentials-provider") { imports = listOf( awsModelFile("sign-in.json"), 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 index 9667c98635c..24b1d32f95d 100644 --- 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 @@ -9,6 +9,8 @@ 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.config.profile.normalizePath import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials import aws.smithy.kotlin.runtime.auth.awscredentials.CredentialsProvider @@ -47,8 +49,10 @@ 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. -internal class DpopInterceptor(private val dpopKeyPem: String) : HttpInterceptor { +/** + * 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) @@ -149,7 +153,8 @@ public class LoginTokenProvider( } } - private fun throwTokenExpired(cause: Throwable? = null): Nothing = throw InvalidLoginTokenException("Login token for login-session: $loginSessionName is expired", cause) + 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 { val telemetry = coroutineContext.telemetryProvider @@ -158,20 +163,17 @@ public class LoginTokenProvider( httpClient = this@LoginTokenProvider.httpClient telemetryProvider = telemetry endpointUrl = Url.parse("https://ap-northeast-1.aws-signin-testing.amazon.com") // TODO: testing endpoint, remove this once service prod endpoint is available - interceptors += DpopInterceptor(oldToken.dpopKey) // note for implementer: this is for writing DpopProof in request header instead of sending in request + interceptors += DpopInterceptor(oldToken.dpopKey) }.use { client -> - val result = client.createOAuth2Token { - dpopProof = generateDpopProof(oldToken.dpopKey, "https://ap-northeast-1.aws-signin-testing.amazon.com/v1/token") // TODO: remove this line once dpopProof being removed from model - tokenInput { - clientId = oldToken.clientId - grantType = "refresh_token" - refreshToken = oldToken.refreshToken + return try { + val result = client.createOAuth2Token { + tokenInput { + clientId = oldToken.clientId + grantType = "refresh_token" + refreshToken = oldToken.refreshToken + } } - } - // TODO: use model provided exception: - // If the CreateOAuth2Token call returns a TBD error with the TBD member set to TBD, then the SDK MUST return an error with TBD wording. - return try { oldToken.copy( accessKeyId = result.tokenOutput!!.accessToken!!.accessKeyId, secretAccessKey = result.tokenOutput.accessToken.secretAccessKey, @@ -180,7 +182,46 @@ public class LoginTokenProvider( refreshToken = result.tokenOutput.refreshToken, ) } catch (e: Exception) { - throw InvalidLoginTokenException("Failed to parse token response", e) + 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 + } } } } @@ -197,11 +238,8 @@ internal data class ECKeyData( * Supports both "EC PRIVATE KEY" and "PRIVATE KEY" PEM formats for P-256 curve keys. */ private fun parseECKeyPem(pem: String): ECKeyData { - // Note: adding PKCS#8 (BEGIN PRIVATE KEY) to support modeled test case, can be removed once model test case is updated val base64 = pem.replace("-----BEGIN EC PRIVATE KEY-----", "") .replace("-----END EC PRIVATE KEY-----", "") - .replace("-----BEGIN PRIVATE KEY-----", "") - .replace("-----END PRIVATE KEY-----", "") .replace("\\s".toRegex(), "") .replace("\n", "") .replace("\r", "") 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 index 7af99542b74..03714953e86 100644 --- 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 @@ -223,7 +223,7 @@ private const val LOGIN_TOKEN_PROVIDER_TEST_SUITE = """ "clientId": "arn:aws:signin:::devtools/same-device", "refreshToken": "refresh_token", "idToken": "eyJraWQiOiI1MzYxMjY2ZS1mNjI5LTQ0ZGQtOTA1My1jYzJkNTM1OTJiOTIiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiJhcm46YXdzOnN0czo6NzIxNzgxNjAzNzU1OmFzc3VtZWQtcm9sZVwvQWRtaW5cL3Nob3ZsaWEtSXNlbmdhcmQiLCJhdWQiOiJhcm46YXdzOnNpZ25pbjo6OmNsaVwvc2FtZS1kZXZpY2UiLCJpc3MiOiJodHRwczpcL1wvc2lnbmluLmF3cy5hbWF6b24uY29tXC9zaWduaW4iLCJzZXNzaW9uX2FybiI6ImFybjphd3M6c3RzOjo3MjE3ODE2MDM3NTU6YXNzdW1lZC1yb2xlXC9BZG1pblwvc2hvdmxpYS1Jc2VuZ2FyZCIsImV4cCI6MTc2MTE2Nzk0NiwiaWF0IjoxNzYxMTY3MDQ2fQ.EzySTg0K11hwQtIYtcBcnNMmX33F6XrVqXsk8WyTWjYcMQxaMnqXebLwBQBCRZha05hZiIZ5xPVCBIt7hZGyymurSfOL72cz69xHUH6u7rwu8vn10UKLHfyKLneKBlmJ", - "dpopKey": "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg+PNauWi/ihtwHHbq\n1tgc8Vgpwx0qQlNSN38y+z0igWehRANCAAR2Ntw6BXJ1v8jb9XjzKZJ+gL5f/3Jq\nIqiH2PUGKWxoFwNlcNB83FivEXEzlTbuCQK5OezOYb3gbvHuzKkB0nDX\n-----END PRIVATE KEY-----" + "dpopKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIPt/u8InPLpQeQLJTvVX+sNDzni8vMDMt3Liu+nMBigfoAoGCCqGSM49\nAwEHoUQDQgAEILkGG7rNOnxiIJlMgimY1UPP8eDMFP0DAY6WGjngP4bvTAiUCQ/I\nffut2379uP+OBCm2ovGpBOJRgrl1RspUOQ==\n-----END EC PRIVATE KEY-----\n" } }, "outcomes": [ @@ -257,7 +257,7 @@ private const val LOGIN_TOKEN_PROVIDER_TEST_SUITE = """ "clientId": "arn:aws:signin:::devtools/same-device", "refreshToken": "valid_refresh_token_456", "idToken": "eyJraWQiOiI1MzYxMjY2ZS1mNjI5LTQ0ZGQtOTA1My1jYzJkNTM1OTJiOTIiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiJhcm46YXdzOnN0czo6NzIxNzgxNjAzNzU1OmFzc3VtZWQtcm9sZVwvQWRtaW5cL3Nob3ZsaWEtSXNlbmdhcmQiLCJhdWQiOiJhcm46YXdzOnNpZ25pbjo6OmNsaVwvc2FtZS1kZXZpY2UiLCJpc3MiOiJodHRwczpcL1wvc2lnbmluLmF3cy5hbWF6b24uY29tXC9zaWduaW4iLCJzZXNzaW9uX2FybiI6ImFybjphd3M6c3RzOjo3MjE3ODE2MDM3NTU6YXNzdW1lZC1yb2xlXC9BZG1pblwvc2hvdmxpYS1Jc2VuZ2FyZCIsImV4cCI6MTc2MTE2Nzk0NiwiaWF0IjoxNzYxMTY3MDQ2fQ.EzySTg0K11hwQtIYtcBcnNMmX33F6XrVqXsk8WyTWjYcMQxaMnqXebLwBQBCRZha05hZiIZ5xPVCBIt7hZGyymurSfOL72cz69xHUH6u7rwu8vn10UKLHfyKLneKBlmJ", - "dpopKey": "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg+PNauWi/ihtwHHbq\n1tgc8Vgpwx0qQlNSN38y+z0igWehRANCAAR2Ntw6BXJ1v8jb9XjzKZJ+gL5f/3Jq\nIqiH2PUGKWxoFwNlcNB83FivEXEzlTbuCQK5OezOYb3gbvHuzKkB0nDX\n-----END PRIVATE KEY-----" + "dpopKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIPt/u8InPLpQeQLJTvVX+sNDzni8vMDMt3Liu+nMBigfoAoGCCqGSM49\nAwEHoUQDQgAEILkGG7rNOnxiIJlMgimY1UPP8eDMFP0DAY6WGjngP4bvTAiUCQ/I\nffut2379uP+OBCm2ovGpBOJRgrl1RspUOQ==\n-----END EC PRIVATE KEY-----\n" } }, "outcomes": [ @@ -281,7 +281,7 @@ private const val LOGIN_TOKEN_PROVIDER_TEST_SUITE = """ }, "clientId": "arn:aws:signin:::devtools/same-device", "idToken": "eyJraWQiOiI1MzYxMjY2ZS1mNjI5LTQ0ZGQtOTA1My1jYzJkNTM1OTJiOTIiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiJhcm46YXdzOnN0czo6NzIxNzgxNjAzNzU1OmFzc3VtZWQtcm9sZVwvQWRtaW5cL3Nob3ZsaWEtSXNlbmdhcmQiLCJhdWQiOiJhcm46YXdzOnNpZ25pbjo6OmNsaVwvc2FtZS1kZXZpY2UiLCJpc3MiOiJodHRwczpcL1wvc2lnbmluLmF3cy5hbWF6b24uY29tXC9zaWduaW4iLCJzZXNzaW9uX2FybiI6ImFybjphd3M6c3RzOjo3MjE3ODE2MDM3NTU6YXNzdW1lZC1yb2xlXC9BZG1pblwvc2hvdmxpYS1Jc2VuZ2FyZCIsImV4cCI6MTc2MTE2Nzk0NiwiaWF0IjoxNzYxMTY3MDQ2fQ.EzySTg0K11hwQtIYtcBcnNMmX33F6XrVqXsk8WyTWjYcMQxaMnqXebLwBQBCRZha05hZiIZ5xPVCBIt7hZGyymurSfOL72cz69xHUH6u7rwu8vn10UKLHfyKLneKBlmJ", - "dpopKey": "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg+PNauWi/ihtwHHbq\n1tgc8Vgpwx0qQlNSN38y+z0igWehRANCAAR2Ntw6BXJ1v8jb9XjzKZJ+gL5f/3Jq\nIqiH2PUGKWxoFwNlcNB83FivEXEzlTbuCQK5OezOYb3gbvHuzKkB0nDX\n-----END PRIVATE KEY-----" + "dpopKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIPt/u8InPLpQeQLJTvVX+sNDzni8vMDMt3Liu+nMBigfoAoGCCqGSM49\nAwEHoUQDQgAEILkGG7rNOnxiIJlMgimY1UPP8eDMFP0DAY6WGjngP4bvTAiUCQ/I\nffut2379uP+OBCm2ovGpBOJRgrl1RspUOQ==\n-----END EC PRIVATE KEY-----\n" } }, "outcomes": [ @@ -305,7 +305,7 @@ private const val LOGIN_TOKEN_PROVIDER_TEST_SUITE = """ }, "refreshToken": "valid_refresh_token_789", "idToken": "eyJraWQiOiI1MzYxMjY2ZS1mNjI5LTQ0ZGQtOTA1My1jYzJkNTM1OTJiOTIiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiJhcm46YXdzOnN0czo6NzIxNzgxNjAzNzU1OmFzc3VtZWQtcm9sZVwvQWRtaW5cL3Nob3ZsaWEtSXNlbmdhcmQiLCJhdWQiOiJhcm46YXdzOnNpZ25pbjo6OmNsaVwvc2FtZS1kZXZpY2UiLCJpc3MiOiJodHRwczpcL1wvc2lnbmluLmF3cy5hbWF6b24uY29tXC9zaWduaW4iLCJzZXNzaW9uX2FybiI6ImFybjphd3M6c3RzOjo3MjE3ODE2MDM3NTU6YXNzdW1lZC1yb2xlXC9BZG1pblwvc2hvdmxpYS1Jc2VuZ2FyZCIsImV4cCI6MTc2MTE2Nzk0NiwiaWF0IjoxNzYxMTY3MDQ2fQ.EzySTg0K11hwQtIYtcBcnNMmX33F6XrVqXsk8WyTWjYcMQxaMnqXebLwBQBCRZha05hZiIZ5xPVCBIt7hZGyymurSfOL72cz69xHUH6u7rwu8vn10UKLHfyKLneKBlmJ", - "dpopKey": "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg+PNauWi/ihtwHHbq\n1tgc8Vgpwx0qQlNSN38y+z0igWehRANCAAR2Ntw6BXJ1v8jb9XjzKZJ+gL5f/3Jq\nIqiH2PUGKWxoFwNlcNB83FivEXEzlTbuCQK5OezOYb3gbvHuzKkB0nDX\n-----END PRIVATE KEY-----" + "dpopKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIPt/u8InPLpQeQLJTvVX+sNDzni8vMDMt3Liu+nMBigfoAoGCCqGSM49\nAwEHoUQDQgAEILkGG7rNOnxiIJlMgimY1UPP8eDMFP0DAY6WGjngP4bvTAiUCQ/I\nffut2379uP+OBCm2ovGpBOJRgrl1RspUOQ==\n-----END EC PRIVATE KEY-----\n" } }, "outcomes": [ @@ -354,13 +354,12 @@ private const val LOGIN_TOKEN_PROVIDER_TEST_SUITE = """ "clientId": "arn:aws:signin:::devtools/same-device", "refreshToken": "valid_refresh_token", "idToken": "eyJraWQiOiI1MzYxMjY2ZS1mNjI5LTQ0ZGQtOTA1My1jYzJkNTM1OTJiOTIiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiJhcm46YXdzOnN0czo6NzIxNzgxNjAzNzU1OmFzc3VtZWQtcm9sZVwvQWRtaW5cL3Nob3ZsaWEtSXNlbmdhcmQiLCJhdWQiOiJhcm46YXdzOnNpZ25pbjo6OmNsaVwvc2FtZS1kZXZpY2UiLCJpc3MiOiJodHRwczpcL1wvc2lnbmluLmF3cy5hbWF6b24uY29tXC9zaWduaW4iLCJzZXNzaW9uX2FybiI6ImFybjphd3M6c3RzOjo3MjE3ODE2MDM3NTU6YXNzdW1lZC1yb2xlXC9BZG1pblwvc2hvdmxpYS1Jc2VuZ2FyZCIsImV4cCI6MTc2MTE2Nzk0NiwiaWF0IjoxNzYxMTY3MDQ2fQ.EzySTg0K11hwQtIYtcBcnNMmX33F6XrVqXsk8WyTWjYcMQxaMnqXebLwBQBCRZha05hZiIZ5xPVCBIt7hZGyymurSfOL72cz69xHUH6u7rwu8vn10UKLHfyKLneKBlmJ", - "dpopKey": "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg+PNauWi/ihtwHHbq\n1tgc8Vgpwx0qQlNSN38y+z0igWehRANCAAR2Ntw6BXJ1v8jb9XjzKZJ+gL5f/3Jq\nIqiH2PUGKWxoFwNlcNB83FivEXEzlTbuCQK5OezOYb3gbvHuzKkB0nDX\n-----END PRIVATE KEY-----" + "dpopKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIPt/u8InPLpQeQLJTvVX+sNDzni8vMDMt3Liu+nMBigfoAoGCCqGSM49\nAwEHoUQDQgAEILkGG7rNOnxiIJlMgimY1UPP8eDMFP0DAY6WGjngP4bvTAiUCQ/I\nffut2379uP+OBCm2ovGpBOJRgrl1RspUOQ==\n-----END EC PRIVATE KEY-----\n" } }, "mockApiCalls": [ { "request": { - "dpopProof": "mock_dpop_proof", "tokenInput": { "clientId": "arn:aws:signin:::devtools/same-device", "refreshToken": "valid_refresh_token", @@ -402,7 +401,7 @@ private const val LOGIN_TOKEN_PROVIDER_TEST_SUITE = """ "clientId": "arn:aws:signin:::devtools/same-device", "refreshToken": "new_refresh_token", "idToken": "eyJraWQiOiI1MzYxMjY2ZS1mNjI5LTQ0ZGQtOTA1My1jYzJkNTM1OTJiOTIiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiJhcm46YXdzOnN0czo6NzIxNzgxNjAzNzU1OmFzc3VtZWQtcm9sZVwvQWRtaW5cL3Nob3ZsaWEtSXNlbmdhcmQiLCJhdWQiOiJhcm46YXdzOnNpZ25pbjo6OmNsaVwvc2FtZS1kZXZpY2UiLCJpc3MiOiJodHRwczpcL1wvc2lnbmluLmF3cy5hbWF6b24uY29tXC9zaWduaW4iLCJzZXNzaW9uX2FybiI6ImFybjphd3M6c3RzOjo3MjE3ODE2MDM3NTU6YXNzdW1lZC1yb2xlXC9BZG1pblwvc2hvdmxpYS1Jc2VuZ2FyZCIsImV4cCI6MTc2MTE2Nzk0NiwiaWF0IjoxNzYxMTY3MDQ2fQ.EzySTg0K11hwQtIYtcBcnNMmX33F6XrVqXsk8WyTWjYcMQxaMnqXebLwBQBCRZha05hZiIZ5xPVCBIt7hZGyymurSfOL72cz69xHUH6u7rwu8vn10UKLHfyKLneKBlmJ", - "dpopKey": "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg+PNauWi/ihtwHHbq\n1tgc8Vgpwx0qQlNSN38y+z0igWehRANCAAR2Ntw6BXJ1v8jb9XjzKZJ+gL5f/3Jq\nIqiH2PUGKWxoFwNlcNB83FivEXEzlTbuCQK5OezOYb3gbvHuzKkB0nDX\n-----END PRIVATE KEY-----" + "dpopKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIPt/u8InPLpQeQLJTvVX+sNDzni8vMDMt3Liu+nMBigfoAoGCCqGSM49\nAwEHoUQDQgAEILkGG7rNOnxiIJlMgimY1UPP8eDMFP0DAY6WGjngP4bvTAiUCQ/I\nffut2379uP+OBCm2ovGpBOJRgrl1RspUOQ==\n-----END EC PRIVATE KEY-----\n" } } ] @@ -422,13 +421,12 @@ private const val LOGIN_TOKEN_PROVIDER_TEST_SUITE = """ "clientId": "arn:aws:signin:::devtools/same-device", "refreshToken": "expired_refresh_token", "idToken": "eyJraWQiOiI1MzYxMjY2ZS1mNjI5LTQ0ZGQtOTA1My1jYzJkNTM1OTJiOTIiLCJ0eXAiOiJKV1QiLCJhbGciOiJFUzM4NCJ9.eyJzdWIiOiJhcm46YXdzOnN0czo6NzIxNzgxNjAzNzU1OmFzc3VtZWQtcm9sZVwvQWRtaW5cL3Nob3ZsaWEtSXNlbmdhcmQiLCJhdWQiOiJhcm46YXdzOnNpZ25pbjo6OmNsaVwvc2FtZS1kZXZpY2UiLCJpc3MiOiJodHRwczpcL1wvc2lnbmluLmF3cy5hbWF6b24uY29tXC9zaWduaW4iLCJzZXNzaW9uX2FybiI6ImFybjphd3M6c3RzOjo3MjE3ODE2MDM3NTU6YXNzdW1lZC1yb2xlXC9BZG1pblwvc2hvdmxpYS1Jc2VuZ2FyZCIsImV4cCI6MTc2MTE2Nzk0NiwiaWF0IjoxNzYxMTY3MDQ2fQ.EzySTg0K11hwQtIYtcBcnNMmX33F6XrVqXsk8WyTWjYcMQxaMnqXebLwBQBCRZha05hZiIZ5xPVCBIt7hZGyymurSfOL72cz69xHUH6u7rwu8vn10UKLHfyKLneKBlmJ", - "dpopKey": "-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg+PNauWi/ihtwHHbq\n1tgc8Vgpwx0qQlNSN38y+z0igWehRANCAAR2Ntw6BXJ1v8jb9XjzKZJ+gL5f/3Jq\nIqiH2PUGKWxoFwNlcNB83FivEXEzlTbuCQK5OezOYb3gbvHuzKkB0nDX\n-----END PRIVATE KEY-----" + "dpopKey": "-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIPt/u8InPLpQeQLJTvVX+sNDzni8vMDMt3Liu+nMBigfoAoGCCqGSM49\nAwEHoUQDQgAEILkGG7rNOnxiIJlMgimY1UPP8eDMFP0DAY6WGjngP4bvTAiUCQ/I\nffut2379uP+OBCm2ovGpBOJRgrl1RspUOQ==\n-----END EC PRIVATE KEY-----\n" } }, "mockApiCalls": [ { "request": { - "dpopProof": "mock_dpop_proof", "tokenInput": { "clientId": "arn:aws:signin:::devtools/same-device", "refreshToken": "expired_refresh_token", diff --git a/codegen/sdk/aws-models/sign-in.json b/codegen/sdk/aws-models/sign-in.json index 804bfe7f80d..a4ac1bbe82e 100644 --- a/codegen/sdk/aws-models/sign-in.json +++ b/codegen/sdk/aws-models/sign-in.json @@ -1,6 +1,29 @@ { "smithy": "2.0", "shapes": { + "com.amazonaws.signin#AccessDeniedException": { + "type": "structure", + "members": { + "error": { + "target": "com.amazonaws.signin#OAuth2ErrorCode", + "traits": { + "smithy.api#documentation": "OAuth 2.0 error code indicating the specific type of access denial\nCan be TOKEN_EXPIRED, AUTHCODE_EXPIRED, USER_CREDENTIALS_CHANGED, or INSUFFICIENT_PERMISSIONS", + "smithy.api#required": {} + } + }, + "message": { + "target": "smithy.api#String", + "traits": { + "smithy.api#documentation": "Detailed message explaining the access denial\nProvides specific information about why access was denied", + "smithy.api#required": {} + } + } + }, + "traits": { + "smithy.api#documentation": "Error thrown for access denied scenarios with flexible HTTP status mapping\n\nRuntime HTTP Status Code Mapping:\n- HTTP 401 (Unauthorized): TOKEN_EXPIRED, AUTHCODE_EXPIRED\n- HTTP 403 (Forbidden): USER_CREDENTIALS_CHANGED, INSUFFICIENT_PERMISSIONS\n\nThe specific HTTP status code is determined at runtime based on the error enum value.\nConsumers should use the error field to determine the specific access denial reason.", + "smithy.api#error": "client" + } + }, "com.amazonaws.signin#AccessToken": { "type": "structure", "members": { @@ -72,57 +95,30 @@ }, "errors": [ { - "target": "com.amazonaws.signin#ForbiddenError" + "target": "com.amazonaws.signin#AccessDeniedException" }, { - "target": "com.amazonaws.signin#InvalidRequestError" + "target": "com.amazonaws.signin#InternalServerException" }, { "target": "com.amazonaws.signin#TooManyRequestsError" }, { - "target": "com.amazonaws.signin#UnauthorizedError" + "target": "com.amazonaws.signin#ValidationException" } ], "traits": { "smithy.api#auth": [], - "smithy.api#documentation": "CreateOAuth2Token API\n\nPath: /v1/token\nRequest Method: POST\nContent-Type: application/json or application/x-www-form-urlencoded\n\nThis API implements OAuth 2.0 flows for AWS Sign-In CLI clients, supporting both:\n1. Authorization code redemption (grant_type=authorization_code) - NOT idempotent\n2. Token refresh (grant_type=refresh_token) - Idempotent within token validity window\n\nThe operation behavior is determined by the grant_type parameter in the request body:\n\n**Authorization Code Flow (NOT Idempotent):**\n- DPoP proof JWT header for demonstrating proof-of-possession of private key\n- JSON or form-encoded body with client_id, grant_type=authorization_code, code, redirect_uri, code_verifier\n- Returns access_token, token_type, expires_in, refresh_token, and id_token\n- Each authorization code can only be used ONCE for security (prevents replay attacks)\n\n**Token Refresh Flow (Idempotent):**\n- DPoP proof JWT header (same private key as original auth_code redemption)\n- JSON or form-encoded body with client_id, grant_type=refresh_token, refresh_token\n- Returns access_token, token_type, expires_in, and refresh_token (no id_token)\n- Multiple calls with same refresh_token return consistent results within validity window\n\nAuthentication and authorization:\n- Confidential clients: sigv4 signing required with signin:ExchangeToken permissions\n- CLI clients (public): authn/authz skipped based on client_id & grant_type\n\nNote: This operation cannot be marked as @idempotent because it handles both idempotent\n(token refresh) and non-idempotent (auth code redemption) flows in a single endpoint.", + "smithy.api#documentation": "CreateOAuth2Token API\n\nPath: /v1/token\nRequest Method: POST\nContent-Type: application/json or application/x-www-form-urlencoded\n\nThis API implements OAuth 2.0 flows for AWS Sign-In CLI clients, supporting both:\n1. Authorization code redemption (grant_type=authorization_code) - NOT idempotent\n2. Token refresh (grant_type=refresh_token) - Idempotent within token validity window\n\nThe operation behavior is determined by the grant_type parameter in the request body:\n\n**Authorization Code Flow (NOT Idempotent):**\n- JSON or form-encoded body with client_id, grant_type=authorization_code, code, redirect_uri, code_verifier\n- Returns access_token, token_type, expires_in, refresh_token, and id_token\n- Each authorization code can only be used ONCE for security (prevents replay attacks)\n\n**Token Refresh Flow (Idempotent):**\n- JSON or form-encoded body with client_id, grant_type=refresh_token, refresh_token\n- Returns access_token, token_type, expires_in, and refresh_token (no id_token)\n- Multiple calls with same refresh_token return consistent results within validity window\n\nAuthentication and authorization:\n- Confidential clients: sigv4 signing required with signin:ExchangeToken permissions\n- CLI clients (public): authn/authz skipped based on client_id & grant_type\n\nNote: This operation cannot be marked as @idempotent because it handles both idempotent\n(token refresh) and non-idempotent (auth code redemption) flows in a single endpoint.", "smithy.api#http": { "method": "POST", "uri": "/v1/token" - }, - "smithy.test#smokeTests": [ - { - "id": "TokenOperationSmokeTest", - "params": { - "dpopProof": "test-dpop-proof", - "tokenInput": { - "clientId": "aws:signin:::cli/same-device", - "grantType": "authorization_code", - "code": "test-code", - "redirectUri": "https://example.com", - "codeVerifier": "test-code-verifier-1234567890abcdefghijklmnop" - } - }, - "vendorParams": {}, - "expect": { - "failure": {} - } - } - ] + } } }, "com.amazonaws.signin#CreateOAuth2TokenRequest": { "type": "structure", "members": { - "dpopProof": { - "target": "com.amazonaws.signin#DPoPProof", - "traits": { - "smithy.api#documentation": "DPoP proof JWT header for demonstrating proof-of-possession of the private key\n\nHeader format: {\"typ\": \"dpop+jwt\", \"alg\": \"ES256\", \"jwk\": {...}}\nPayload format: {\"htm\": \"POST\", \"htu\": \"https://server.example.com/token\", \"iat\": timestamp, \"jti\": \"unique-id\"}\nMust be signed with the private key corresponding to the embedded jwk", - "smithy.api#httpHeader": "DPoP", - "smithy.api#required": {} - } - }, "tokenInput": { "target": "com.amazonaws.signin#CreateOAuth2TokenRequestBody", "traits": { @@ -252,16 +248,6 @@ "smithy.api#documentation": "Response body payload for CreateOAuth2Token operation\n\nThe response content depends on the grant_type from the request:\n- grant_type=authorization_code: Returns all fields including refresh_token and id_token\n- grant_type=refresh_token: Returns access_token, token_type, expires_in, refresh_token (no id_token)" } }, - "com.amazonaws.signin#DPoPProof": { - "type": "string", - "traits": { - "smithy.api#documentation": "DPoP proof JWT for demonstrating proof-of-possession\n\nDPoP (Demonstration of Proof-of-Possession) JWT header structure:\nHeader: {\"typ\": \"dpop+jwt\", \"alg\": \"ES256\", \"jwk\": {...}}\nPayload: {\"htm\": \"POST\", \"htu\": \"https://server.example.com/token\", \"iat\": timestamp, \"jti\": \"unique-id\"}\n\nKey components:\n- htm: HTTP method being used (POST)\n- htu: HTTP URI being accessed (e.g., https://us-east-1.signin.aws.amazon.com/v1/token)\n- iat: Issued at timestamp\n- jti: Unique identifier for the JWT\n\nFor token refresh: Must be generated with same private key as original auth_code redemption.\nOnly iat changes (within acceptable clock skew).", - "smithy.api#length": { - "min": 1, - "max": 4096 - } - } - }, "com.amazonaws.signin#ExpiresIn": { "type": "integer", "traits": { @@ -272,23 +258,6 @@ } } }, - "com.amazonaws.signin#ForbiddenError": { - "type": "structure", - "members": { - "message": { - "target": "smithy.api#String", - "traits": { - "smithy.api#documentation": "Detailed message explaining the authorization failure\nProvides information about permission or policy violations", - "smithy.api#required": {} - } - } - }, - "traits": { - "smithy.api#documentation": "Error thrown when the client is not authorized or has insufficient permissions\n\nHTTP Status Code: 403 Forbidden\n\nPossible causes:\n- Unauthorized widget or client_id not registered\n- Insufficient permissions for the requested scope\n- Client not authorized to use the specified grant_type\n- DPoP key binding validation failure (cnf.jkt mismatch)\n- Policy evaluation failure for credential scoping", - "smithy.api#error": "client", - "smithy.api#httpError": 403 - } - }, "com.amazonaws.signin#GrantType": { "type": "string", "traits": { @@ -306,21 +275,78 @@ } } }, - "com.amazonaws.signin#InvalidRequestError": { + "com.amazonaws.signin#InternalServerException": { "type": "structure", "members": { + "error": { + "target": "com.amazonaws.signin#OAuth2ErrorCode", + "traits": { + "smithy.api#documentation": "OAuth 2.0 error code indicating server error\nWill be SERVER_ERROR for internal server errors", + "smithy.api#required": {} + } + }, "message": { "target": "smithy.api#String", "traits": { - "smithy.api#documentation": "Detailed message explaining the request validation failure\nProvides specific information about which parameter or validation failed", + "smithy.api#documentation": "Detailed message explaining the server error\nMay include error details for debugging purposes", "smithy.api#required": {} } } }, "traits": { - "smithy.api#documentation": "Error thrown when the request is malformed or missing required parameters\n\nHTTP Status Code: 400 Bad Request\n\nPossible causes:\n- Malformed request body (invalid form encoding)\n- Missing required parameters (client_id, grant_type, code, etc.)\n- Invalid parameter values (malformed client_id ARN, invalid grant_type)\n- Invalid DPoP proof JWT format or signature\n- PKCE code_verifier validation failure\n- Redirect URI mismatch with original authorization request", - "smithy.api#error": "client", - "smithy.api#httpError": 400 + "smithy.api#documentation": "Error thrown when an internal server error occurs\n\nHTTP Status Code: 500 Internal Server Error\n\nUsed for unexpected server-side errors that prevent request processing.", + "smithy.api#error": "server", + "smithy.api#httpError": 500 + } + }, + "com.amazonaws.signin#OAuth2ErrorCode": { + "type": "enum", + "members": { + "TOKEN_EXPIRED": { + "target": "smithy.api#Unit", + "traits": { + "smithy.api#documentation": "Token has expired and needs to be refreshed", + "smithy.api#enumValue": "TOKEN_EXPIRED" + } + }, + "USER_CREDENTIALS_CHANGED": { + "target": "smithy.api#Unit", + "traits": { + "smithy.api#documentation": "User credentials have been changed", + "smithy.api#enumValue": "USER_CREDENTIALS_CHANGED" + } + }, + "INSUFFICIENT_PERMISSIONS": { + "target": "smithy.api#Unit", + "traits": { + "smithy.api#documentation": "Insufficient permissions to perform this operation", + "smithy.api#enumValue": "INSUFFICIENT_PERMISSIONS" + } + }, + "AUTHCODE_EXPIRED": { + "target": "smithy.api#Unit", + "traits": { + "smithy.api#documentation": "Authorization code has expired", + "smithy.api#enumValue": "AUTHCODE_EXPIRED" + } + }, + "SERVER_ERROR": { + "target": "smithy.api#Unit", + "traits": { + "smithy.api#documentation": "Internal server error occurred", + "smithy.api#enumValue": "server_error" + } + }, + "INVALID_REQUEST": { + "target": "smithy.api#Unit", + "traits": { + "smithy.api#documentation": "The request is missing a required parameter, includes an invalid parameter value, or is otherwise malformed", + "smithy.api#enumValue": "INVALID_REQUEST" + } + } + }, + "traits": { + "smithy.api#documentation": "OAuth 2.0 error codes returned by the server\n\nStandard OAuth 2.0 error codes used in error responses to indicate\nthe specific type of error that occurred during token operations." } }, "com.amazonaws.signin#RedirectUri": { @@ -999,53 +1025,27 @@ } }, { - "documentation": "For region us-gov-west-1 with FIPS enabled and DualStack enabled", - "expect": { - "endpoint": { - "url": "https://signin-fips.us-gov-west-1.api.aws" - } - }, - "params": { - "Region": "us-gov-west-1", - "UseFIPS": true, - "UseDualStack": true - } - }, - { - "documentation": "For region us-gov-west-1 with FIPS enabled and DualStack disabled", + "documentation": "For region eusc-de-east-1 with FIPS enabled and DualStack disabled", "expect": { "endpoint": { - "url": "https://signin-fips.us-gov-west-1.amazonaws.com" + "url": "https://signin-fips.eusc-de-east-1.amazonaws.eu" } }, "params": { - "Region": "us-gov-west-1", + "Region": "eusc-de-east-1", "UseFIPS": true, "UseDualStack": false } }, { - "documentation": "For region us-gov-west-1 with FIPS disabled and DualStack enabled", - "expect": { - "endpoint": { - "url": "https://signin.us-gov-west-1.api.aws" - } - }, - "params": { - "Region": "us-gov-west-1", - "UseFIPS": false, - "UseDualStack": true - } - }, - { - "documentation": "For region us-gov-west-1 with FIPS disabled and DualStack disabled", + "documentation": "For region eusc-de-east-1 with FIPS disabled and DualStack disabled", "expect": { "endpoint": { - "url": "https://us-gov-west-1.signin.amazonaws-us-gov.com" + "url": "https://signin.eusc-de-east-1.amazonaws.eu" } }, "params": { - "Region": "us-gov-west-1", + "Region": "eusc-de-east-1", "UseFIPS": false, "UseDualStack": false } @@ -1155,27 +1155,53 @@ } }, { - "documentation": "For region eusc-de-east-1 with FIPS enabled and DualStack disabled", + "documentation": "For region us-gov-west-1 with FIPS enabled and DualStack enabled", "expect": { "endpoint": { - "url": "https://signin-fips.eusc-de-east-1.amazonaws.eu" + "url": "https://signin-fips.us-gov-west-1.api.aws" } }, "params": { - "Region": "eusc-de-east-1", + "Region": "us-gov-west-1", + "UseFIPS": true, + "UseDualStack": true + } + }, + { + "documentation": "For region us-gov-west-1 with FIPS enabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://signin-fips.us-gov-west-1.amazonaws.com" + } + }, + "params": { + "Region": "us-gov-west-1", "UseFIPS": true, "UseDualStack": false } }, { - "documentation": "For region eusc-de-east-1 with FIPS disabled and DualStack disabled", + "documentation": "For region us-gov-west-1 with FIPS disabled and DualStack enabled", "expect": { "endpoint": { - "url": "https://signin.eusc-de-east-1.amazonaws.eu" + "url": "https://signin.us-gov-west-1.api.aws" } }, "params": { - "Region": "eusc-de-east-1", + "Region": "us-gov-west-1", + "UseFIPS": false, + "UseDualStack": true + } + }, + { + "documentation": "For region us-gov-west-1 with FIPS disabled and DualStack disabled", + "expect": { + "endpoint": { + "url": "https://us-gov-west-1.signin.amazonaws-us-gov.com" + } + }, + "params": { + "Region": "us-gov-west-1", "UseFIPS": false, "UseDualStack": false } @@ -1201,6 +1227,13 @@ "com.amazonaws.signin#TooManyRequestsError": { "type": "structure", "members": { + "error": { + "target": "com.amazonaws.signin#OAuth2ErrorCode", + "traits": { + "smithy.api#documentation": "OAuth 2.0 error code indicating the specific type of error\nWill be INVALID_REQUEST for rate limiting scenarios", + "smithy.api#required": {} + } + }, "message": { "target": "smithy.api#String", "traits": { @@ -1210,26 +1243,33 @@ } }, "traits": { - "smithy.api#documentation": "Error thrown when rate limit is exceeded\n\nHTTP Status Code: 429 Too Many Requests\n\nPossible causes:\n- Too many token requests from the same client\n- Rate limiting based on client_id or IP address\n- Abuse prevention mechanisms triggered\n- Service protection against excessive token generation", + "smithy.api#documentation": "Error thrown when rate limit is exceeded\n\nHTTP Status Code: 429 Too Many Requests\n\nPossible OAuth2ErrorCode values:\n- INVALID_REQUEST: Rate limiting, too many requests, abuse prevention\n\nPossible causes:\n- Too many token requests from the same client\n- Rate limiting based on client_id or IP address\n- Abuse prevention mechanisms triggered\n- Service protection against excessive token generation", "smithy.api#error": "client", "smithy.api#httpError": 429 } }, - "com.amazonaws.signin#UnauthorizedError": { + "com.amazonaws.signin#ValidationException": { "type": "structure", "members": { + "error": { + "target": "com.amazonaws.signin#OAuth2ErrorCode", + "traits": { + "smithy.api#documentation": "OAuth 2.0 error code indicating validation failure\nWill be INVALID_REQUEST for validation errors", + "smithy.api#required": {} + } + }, "message": { "target": "smithy.api#String", "traits": { - "smithy.api#documentation": "Detailed message explaining the authentication failure\nIndicates specific authentication issue (expired token, invalid signature, etc.)", + "smithy.api#documentation": "Detailed message explaining the validation failure\nProvides specific information about which validation failed", "smithy.api#required": {} } } }, "traits": { - "smithy.api#documentation": "Error thrown when the access token is invalid or expired\n\nHTTP Status Code: 401 Unauthorized\n\nPossible causes:\n- Invalid or expired authorization code\n- Invalid or expired refresh token\n- DPoP proof JWT validation failure (invalid signature, expired, wrong key)\n- Authorization code already used (replay attack prevention)\n- Refresh token revoked or invalid", + "smithy.api#documentation": "Error thrown when request validation fails\n\nHTTP Status Code: 400 Bad Request\n\nUsed for request validation errors such as malformed parameters,\nmissing required fields, or invalid parameter values.", "smithy.api#error": "client", - "smithy.api#httpError": 401 + "smithy.api#httpError": 400 } } } From ba82a4130714fb684d2dd8cf2f8ef3bd838461ef Mon Sep 17 00:00:00 2001 From: 0marperez Date: Mon, 10 Nov 2025 20:45:07 -0500 Subject: [PATCH 20/31] self review --- aws-runtime/aws-config/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws-runtime/aws-config/build.gradle.kts b/aws-runtime/aws-config/build.gradle.kts index 60abef0e87e..e4b8ddaf8d6 100644 --- a/aws-runtime/aws-config/build.gradle.kts +++ b/aws-runtime/aws-config/build.gradle.kts @@ -189,7 +189,7 @@ smithyBuild { ) } - // FIXME: Shape from smoke tests fails projection: aws.test#AwsVendorParams + // FIXME: Shape from smoke tests fails projection: aws.test#AwsVendorParams (curr smoke tests temporarily removed from model) create("signin-credentials-provider") { imports = listOf( awsModelFile("sign-in.json"), From dcd5a043149cb09fadc045d0c0452efe299617e8 Mon Sep 17 00:00:00 2001 From: 0marperez Date: Mon, 10 Nov 2025 20:46:18 -0500 Subject: [PATCH 21/31] remove testing endpoint --- .../sdk/kotlin/runtime/auth/credentials/LoginTokenProvider.kt | 1 - 1 file changed, 1 deletion(-) 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 index 24b1d32f95d..d929dbdce58 100644 --- 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 @@ -162,7 +162,6 @@ public class LoginTokenProvider( SigninClient.fromEnvironment { httpClient = this@LoginTokenProvider.httpClient telemetryProvider = telemetry - endpointUrl = Url.parse("https://ap-northeast-1.aws-signin-testing.amazon.com") // TODO: testing endpoint, remove this once service prod endpoint is available interceptors += DpopInterceptor(oldToken.dpopKey) }.use { client -> return try { From f7b019343e47e977d878bde1aa6f3a9e40f02a05 Mon Sep 17 00:00:00 2001 From: 0marperez Date: Mon, 10 Nov 2025 20:50:34 -0500 Subject: [PATCH 22/31] oops, give values to business metrics --- .../interceptors/businessmetrics/AwsBusinessMetricsUtils.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 e30cb132fe9..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,8 +84,8 @@ public enum class AwsBusinessMetric(public override val identifier: String) : Bu CREDENTIALS_PROCESS("w"), CREDENTIALS_HTTP("z"), CREDENTIALS_IMDS("0"), - CREDENTIALS_PROFILE_LOGIN("TBD"), - CREDENTIALS_LOGIN("TBD"), + CREDENTIALS_PROFILE_LOGIN("AC"), + CREDENTIALS_LOGIN("AD"), } override fun toString(): String = identifier From 06422f137d41225cecd8144b8af8278bc320e2e3 Mon Sep 17 00:00:00 2001 From: 0marperez Date: Mon, 10 Nov 2025 21:26:14 -0500 Subject: [PATCH 23/31] fix broken code after removing hardcoded URL --- aws-runtime/aws-config/api/aws-config.api | 10 ++++++---- .../auth/credentials/LoginCredentialsProvider.kt | 3 +++ .../runtime/auth/credentials/LoginTokenProvider.kt | 14 ++++++++++---- .../auth/credentials/ProfileCredentialsProvider.kt | 2 +- .../credentials/LoginCredentialsProviderTest.kt | 1 + 5 files changed, 21 insertions(+), 9 deletions(-) diff --git a/aws-runtime/aws-config/api/aws-config.api b/aws-runtime/aws-config/api/aws-config.api index c66a69067bc..6d9bb114aef 100644 --- a/aws-runtime/aws-config/api/aws-config.api +++ b/aws-runtime/aws-config/api/aws-config.api @@ -103,21 +103,23 @@ public final class aws/sdk/kotlin/runtime/auth/credentials/InvalidSsoTokenExcept } public final class aws/sdk/kotlin/runtime/auth/credentials/LoginCredentialsProvider : aws/smithy/kotlin/runtime/auth/awscredentials/CredentialsProvider { - public fun (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;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 (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 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/LoginTokenProvider : aws/smithy/kotlin/runtime/auth/awscredentials/CredentialsProvider { - public synthetic fun (Ljava/lang/String;JLaws/smithy/kotlin/runtime/http/engine/HttpClientEngine;Laws/smithy/kotlin/runtime/util/PlatformProvider;Laws/smithy/kotlin/runtime/time/Clock;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (Ljava/lang/String;JLaws/smithy/kotlin/runtime/http/engine/HttpClientEngine;Laws/smithy/kotlin/runtime/util/PlatformProvider;Laws/smithy/kotlin/runtime/time/Clock;Lkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;JLaws/smithy/kotlin/runtime/http/engine/HttpClientEngine;Laws/smithy/kotlin/runtime/util/PlatformProvider;Laws/smithy/kotlin/runtime/time/Clock;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;JLaws/smithy/kotlin/runtime/http/engine/HttpClientEngine;Laws/smithy/kotlin/runtime/util/PlatformProvider;Laws/smithy/kotlin/runtime/time/Clock;Lkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun getHttpClient ()Laws/smithy/kotlin/runtime/http/engine/HttpClientEngine; public final fun getLoginSessionName ()Ljava/lang/String; public final fun getPlatformProvider ()Laws/smithy/kotlin/runtime/util/PlatformProvider; public final fun getRefreshBufferWindow-UwyO8pc ()J + public final fun getRegion ()Ljava/lang/String; public fun resolve (Laws/smithy/kotlin/runtime/collections/Attributes;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } 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 index ca851af55de..39f57321c79 100644 --- 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 @@ -44,6 +44,7 @@ import kotlin.coroutines.coroutineContext * 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 @@ -51,6 +52,7 @@ import kotlin.coroutines.coroutineContext */ 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, @@ -61,6 +63,7 @@ public class LoginCredentialsProvider public constructor( val loginTokenProvider = LoginTokenProvider( loginSession, + region, httpClient = httpClient, platformProvider = platformProvider, clock = clock, 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 index d929dbdce58..08c6a31a25b 100644 --- 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 @@ -24,7 +24,6 @@ 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.net.url.Url import aws.smithy.kotlin.runtime.serde.json.JsonToken import aws.smithy.kotlin.runtime.serde.json.jsonStreamReader import aws.smithy.kotlin.runtime.serde.json.jsonStreamWriter @@ -77,6 +76,7 @@ private class DpopInterceptor(private val dpopKeyPem: String) : HttpInterceptor * 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. @@ -87,6 +87,7 @@ private class DpopInterceptor(private val dpopKeyPem: String) : HttpInterceptor */ public class LoginTokenProvider( public val loginSessionName: String, + public val region: String? = null, public val refreshBufferWindow: Duration = DEFAULT_SIGNIN_TOKEN_REFRESH_BUFFER_SECONDS.seconds, public val httpClient: HttpClientEngine? = null, public val platformProvider: PlatformProvider = PlatformProvider.System, @@ -119,10 +120,10 @@ public class LoginTokenProvider( return try { attemptRefresh(token) - } catch (_: Exception) { + } 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() + } ?: throwTokenExpired(e) } } @@ -160,6 +161,7 @@ public class LoginTokenProvider( val telemetry = coroutineContext.telemetryProvider SigninClient.fromEnvironment { + region = this@LoginTokenProvider.region httpClient = this@LoginTokenProvider.httpClient telemetryProvider = telemetry interceptors += DpopInterceptor(oldToken.dpopKey) @@ -267,7 +269,11 @@ private fun parseECKeyPem(pem: String): ECKeyData { } private fun ByteArray.padTo32(): ByteArray = - if (size >= 32) takeLast(32).toByteArray() else ByteArray(32 - size) + this + if (size >= 32) { + takeLast(32).toByteArray() + } else { + ByteArray(32 - size) + this + } /** * Generates a DPoP (Demonstration of Proof-of-Possession) JWT proof for OAuth 2.0 requests. 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 ff289164f29..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,7 +200,7 @@ public class ProfileCredentialsProvider @InternalSdkApi constructor( credentialsBusinessMetrics.add(AwsBusinessMetric.Credentials.CREDENTIALS_PROFILE_SSO_LEGACY) } - is LeafProvider.LoginSession -> LoginCredentialsProvider(loginSessionName).also { + is LeafProvider.LoginSession -> LoginCredentialsProvider(loginSessionName, region.get()).also { credentialsBusinessMetrics.add(AwsBusinessMetric.Credentials.CREDENTIALS_PROFILE_LOGIN) } 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 index 4a79c734ba7..d8d0744612c 100644 --- 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 @@ -128,6 +128,7 @@ class LoginCredentialsProviderTest { val provider = LoginCredentialsProvider( loginSession = "arn:aws:iam::123456789:user/TestUser", + region = "us-west-2", httpClient = engine, platformProvider = testPlatform, clock = testClock, From 7f285c538e433e4bbef6542c897e3229f327d284 Mon Sep 17 00:00:00 2001 From: 0marperez Date: Thu, 13 Nov 2025 17:06:05 -0500 Subject: [PATCH 24/31] feedback and fixes --- aws-runtime/aws-config/build.gradle.kts | 5 ++++- .../credentials/LoginCredentialsProvider.kt | 8 +++---- .../auth/credentials/LoginTokenProvider.kt | 9 ++++++-- .../auth/credentials/profile/ProfileChain.kt | 2 +- codegen/sdk/aws-models/sign-in.json | 21 ++++++++++++++++++- codegen/sdk/aws-shapes/shapes.json | 9 ++++++++ 6 files changed, 44 insertions(+), 10 deletions(-) create mode 100644 codegen/sdk/aws-shapes/shapes.json diff --git a/aws-runtime/aws-config/build.gradle.kts b/aws-runtime/aws-config/build.gradle.kts index e4b8ddaf8d6..c7984b3c1f9 100644 --- a/aws-runtime/aws-config/build.gradle.kts +++ b/aws-runtime/aws-config/build.gradle.kts @@ -71,6 +71,9 @@ kotlin { fun awsModelFile(name: String): String = rootProject.file("codegen/sdk/aws-models/$name").relativeTo(project.layout.buildDirectory.get().asFile).toString() +fun awsShapeFile(name: String): String = + rootProject.file("codegen/sdk/aws-shapes/$name").relativeTo(project.layout.buildDirectory.get().asFile).toString() + val codegen by configurations.getting dependencies { codegen(project(":codegen:aws-sdk-codegen")) @@ -189,10 +192,10 @@ smithyBuild { ) } - // FIXME: Shape from smoke tests fails projection: aws.test#AwsVendorParams (curr smoke tests temporarily removed from model) create("signin-credentials-provider") { imports = listOf( awsModelFile("sign-in.json"), + awsShapeFile("shapes.json"), ) val serviceShape = "com.amazonaws.signin#Signin" 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 index 39f57321c79..29be3c4ac1a 100644 --- 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 @@ -32,12 +32,10 @@ import kotlin.coroutines.coroutineContext * the directory specified by the `AWS_LOGIN_CACHE_DIRECTORY` environment variable. * * ``` - * val source = LoginCredentialsProvider( - * loginSession = "my-login-session" - * ) - * * // Wrap the provider with a caching provider to cache the credentials until their expiration time - * val loginProvider = CachedCredentialsProvider(source) + * 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 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 index 08c6a31a25b..d3a662ab5fb 100644 --- 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 @@ -144,13 +144,14 @@ public class LoginTokenProvider( private suspend fun writeToken(refreshed: LoginToken) { val cacheKey = getLoginCacheFilename(loginSessionName) - val directory = platformProvider.getenv("AWS_LOGIN_IN_CACHE_DIRECTORY") ?: platformProvider.filepath("~", ".aws", "login", "cache") + val directory = resolveCacheDir(platformProvider) val filepath = normalizePath(platformProvider.filepath(directory, 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 } } @@ -333,13 +334,17 @@ private fun generateDpopProof( internal suspend fun readLoginTokenFromCache(cacheKey: String, platformProvider: PlatformProvider): LoginToken { val key = getLoginCacheFilename(cacheKey) val bytes = with(platformProvider) { - val directory = getenv("AWS_LOGIN_IN_CACHE_DIRECTORY") ?: filepath("~", ".aws", "login", "cache") + val directory = resolveCacheDir(this) val defaultCacheLocation = normalizePath(directory, this) readFileOrNull(filepath(defaultCacheLocation, key)) } ?: throw ProviderConfigurationException("Invalid or missing login session cache. Run `aws login` to initiate a new session") return deserializeLoginToken(bytes) } +private fun resolveCacheDir(platformProvider: PlatformProvider) = + platformProvider.getenv("AWS_LOGIN_IN_CACHE_DIRECTORY") + ?: platformProvider.filepath("~", ".aws", "login", "cache") + internal fun getLoginCacheFilename(cacheKey: String): String { val sha256HexDigest = cacheKey.trim().encodeToByteArray().sha256().encodeToHex() return "$sha256HexDigest.json" 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 ee45438bab0..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 @@ -359,7 +359,7 @@ private fun AwsProfile.leafProvider(config: AwsSharedConfig): LeafProvider { return webIdentityTokenCreds() .orElse { ssoSessionCreds(config) } .orElse(::legacySsoCreds) - .orElse { loginSessionCreds() } + .orElse(::loginSessionCreds) .unwrapOrElse(::processCreds) .unwrap() } diff --git a/codegen/sdk/aws-models/sign-in.json b/codegen/sdk/aws-models/sign-in.json index a4ac1bbe82e..9ca07a4fe4b 100644 --- a/codegen/sdk/aws-models/sign-in.json +++ b/codegen/sdk/aws-models/sign-in.json @@ -113,7 +113,26 @@ "smithy.api#http": { "method": "POST", "uri": "/v1/token" - } + }, + "smithy.test#smokeTests": [ + { + "id": "TokenOperationSmokeTest", + "params": { + "tokenInput": { + "clientId": "aws:signin:::cli/same-device", + "grantType": "authorization_code", + "code": "test-code", + "redirectUri": "https://example.com", + "codeVerifier": "test-code-verifier-1234567890abcdefghijklmnop" + } + }, + "vendorParamsShape": "aws.test#AwsVendorParams", + "vendorParams": {}, + "expect": { + "failure": {} + } + } + ] } }, "com.amazonaws.signin#CreateOAuth2TokenRequest": { diff --git a/codegen/sdk/aws-shapes/shapes.json b/codegen/sdk/aws-shapes/shapes.json new file mode 100644 index 00000000000..9f29185cdbf --- /dev/null +++ b/codegen/sdk/aws-shapes/shapes.json @@ -0,0 +1,9 @@ +{ + "smithy": "2.0", + "shapes": { + "aws.test#AwsVendorParams": { + "type": "structure", + "members": {} + } + } +} From c77546f432a62790c7521e2c32e127ab9976a5ff Mon Sep 17 00:00:00 2001 From: 0marperez Date: Fri, 14 Nov 2025 15:06:18 -0500 Subject: [PATCH 25/31] feedback --- aws-runtime/aws-config/api/aws-config.api | 14 +------ aws-runtime/aws-config/build.gradle.kts | 5 +-- .../credentials/LoginCredentialsProvider.kt | 23 ++++++++++- .../auth/credentials/LoginTokenProvider.kt | 39 +++++++------------ .../credentials/LoginTokenProviderTest.kt | 2 + codegen/sdk/aws-shapes/shapes.json | 9 ----- 6 files changed, 42 insertions(+), 50 deletions(-) delete mode 100644 codegen/sdk/aws-shapes/shapes.json diff --git a/aws-runtime/aws-config/api/aws-config.api b/aws-runtime/aws-config/api/aws-config.api index 6d9bb114aef..6b7718887cb 100644 --- a/aws-runtime/aws-config/api/aws-config.api +++ b/aws-runtime/aws-config/api/aws-config.api @@ -102,9 +102,10 @@ public final class aws/sdk/kotlin/runtime/auth/credentials/InvalidSsoTokenExcept 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/CredentialsProvider { +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; @@ -112,17 +113,6 @@ public final class aws/sdk/kotlin/runtime/auth/credentials/LoginCredentialsProvi public fun resolve (Laws/smithy/kotlin/runtime/collections/Attributes;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } -public final class aws/sdk/kotlin/runtime/auth/credentials/LoginTokenProvider : aws/smithy/kotlin/runtime/auth/awscredentials/CredentialsProvider { - public synthetic fun (Ljava/lang/String;Ljava/lang/String;JLaws/smithy/kotlin/runtime/http/engine/HttpClientEngine;Laws/smithy/kotlin/runtime/util/PlatformProvider;Laws/smithy/kotlin/runtime/time/Clock;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - public synthetic fun (Ljava/lang/String;Ljava/lang/String;JLaws/smithy/kotlin/runtime/http/engine/HttpClientEngine;Laws/smithy/kotlin/runtime/util/PlatformProvider;Laws/smithy/kotlin/runtime/time/Clock;Lkotlin/jvm/internal/DefaultConstructorMarker;)V - public final fun getHttpClient ()Laws/smithy/kotlin/runtime/http/engine/HttpClientEngine; - public final fun getLoginSessionName ()Ljava/lang/String; - public final fun getPlatformProvider ()Laws/smithy/kotlin/runtime/util/PlatformProvider; - public final fun getRefreshBufferWindow-UwyO8pc ()J - 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 diff --git a/aws-runtime/aws-config/build.gradle.kts b/aws-runtime/aws-config/build.gradle.kts index c7984b3c1f9..5b13108c133 100644 --- a/aws-runtime/aws-config/build.gradle.kts +++ b/aws-runtime/aws-config/build.gradle.kts @@ -71,14 +71,12 @@ kotlin { fun awsModelFile(name: String): String = rootProject.file("codegen/sdk/aws-models/$name").relativeTo(project.layout.buildDirectory.get().asFile).toString() -fun awsShapeFile(name: String): String = - rootProject.file("codegen/sdk/aws-shapes/$name").relativeTo(project.layout.buildDirectory.get().asFile).toString() - val codegen by configurations.getting dependencies { codegen(project(":codegen:aws-sdk-codegen")) codegen(libs.smithy.cli) codegen(libs.smithy.model) + codegen(libs.smithy.aws.smoke.test.model) } smithyBuild { @@ -195,7 +193,6 @@ smithyBuild { create("signin-credentials-provider") { imports = listOf( awsModelFile("sign-in.json"), - awsShapeFile("shapes.json"), ) val serviceShape = "com.amazonaws.signin#Signin" 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 index 29be3c4ac1a..2b71ec8c10c 100644 --- 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 @@ -5,8 +5,10 @@ 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 @@ -14,6 +16,7 @@ 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 /** @@ -54,7 +57,10 @@ public class LoginCredentialsProvider public constructor( public val httpClient: HttpClientEngine? = null, public val platformProvider: PlatformProvider = PlatformProvider.System, private val clock: Clock = Clock.System, -) : CredentialsProvider { +) : CloseableCredentialsProvider { + private val cacheDirectory = resolveCacheDir(platformProvider) + private val client = runBlocking { signinClient(region, httpClient) } + override suspend fun resolve(attributes: Attributes): Credentials { val logger = coroutineContext.logger() @@ -65,6 +71,8 @@ public class LoginCredentialsProvider public constructor( httpClient = httpClient, platformProvider = platformProvider, clock = clock, + cacheDirectory = cacheDirectory, + client = client, ) logger.trace { "Attempting to load token using token provider for login-session: `$loginSession`" } @@ -72,4 +80,17 @@ public class LoginCredentialsProvider public constructor( 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 index d3a662ab5fb..1ac9461a983 100644 --- 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 @@ -11,6 +11,7 @@ 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 @@ -30,7 +31,6 @@ 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.telemetry.telemetryProvider import aws.smithy.kotlin.runtime.text.encoding.decodeBase64Bytes import aws.smithy.kotlin.runtime.text.encoding.encodeToHex import aws.smithy.kotlin.runtime.time.Clock @@ -85,13 +85,15 @@ private class DpopInterceptor(private val dpopKeyPem: String) : HttpInterceptor * @param platformProvider the platform provider to use * @param clock the source of time for the provider */ -public class LoginTokenProvider( - public val loginSessionName: String, - public val region: String? = null, - public val refreshBufferWindow: Duration = DEFAULT_SIGNIN_TOKEN_REFRESH_BUFFER_SECONDS.seconds, - public val httpClient: HttpClientEngine? = null, - public val platformProvider: PlatformProvider = PlatformProvider.System, - private val clock: Clock = Clock.System, +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 @@ -111,7 +113,7 @@ public class LoginTokenProvider( } private suspend fun getToken(attributes: Attributes): LoginToken { - val token = readLoginTokenFromCache(loginSessionName, platformProvider) + val token = readLoginTokenFromCache(loginSessionName, platformProvider, cacheDirectory) if (clock.now() < (token.expiresAt - refreshBufferWindow)) { coroutineContext.debug { "using cached token for login-session: $loginSessionName" } @@ -144,8 +146,7 @@ public class LoginTokenProvider( private suspend fun writeToken(refreshed: LoginToken) { val cacheKey = getLoginCacheFilename(loginSessionName) - val directory = resolveCacheDir(platformProvider) - val filepath = normalizePath(platformProvider.filepath(directory, cacheKey), platformProvider) + val filepath = normalizePath(platformProvider.filepath(cacheDirectory, cacheKey), platformProvider) val contents = serializeLoginToken(refreshed) try { platformProvider.writeFile(filepath, contents) @@ -159,12 +160,7 @@ public class LoginTokenProvider( throw InvalidLoginTokenException(message ?: "Login token for login-session: $loginSessionName is expired", cause) private suspend fun refreshToken(oldToken: LoginToken): LoginToken { - val telemetry = coroutineContext.telemetryProvider - - SigninClient.fromEnvironment { - region = this@LoginTokenProvider.region - httpClient = this@LoginTokenProvider.httpClient - telemetryProvider = telemetry + client.withConfig { interceptors += DpopInterceptor(oldToken.dpopKey) }.use { client -> return try { @@ -331,20 +327,15 @@ private fun generateDpopProof( return "$message.${ base64UrlNoPadding.encode(signature) }" } -internal suspend fun readLoginTokenFromCache(cacheKey: String, platformProvider: PlatformProvider): LoginToken { +internal suspend fun readLoginTokenFromCache(cacheKey: String, platformProvider: PlatformProvider, cacheDirectory: String): LoginToken { val key = getLoginCacheFilename(cacheKey) val bytes = with(platformProvider) { - val directory = resolveCacheDir(this) - val defaultCacheLocation = normalizePath(directory, this) + 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) } -private fun resolveCacheDir(platformProvider: PlatformProvider) = - platformProvider.getenv("AWS_LOGIN_IN_CACHE_DIRECTORY") - ?: platformProvider.filepath("~", ".aws", "login", "cache") - internal fun getLoginCacheFilename(cacheKey: String): String { val sha256HexDigest = cacheKey.trim().encodeToByteArray().sha256().encodeToHex() return "$sha256HexDigest.json" 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 index 03714953e86..98da76189ad 100644 --- 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 @@ -152,6 +152,8 @@ class LoginTokenProviderTest { httpClient = httpClient, platformProvider = testPlatform, clock = testClock, + cacheDirectory = resolveCacheDir(testPlatform), + client = signinClient(providedHttpClient = httpClient), ) testCase.outcomes.forEach { expectedOutcome -> diff --git a/codegen/sdk/aws-shapes/shapes.json b/codegen/sdk/aws-shapes/shapes.json deleted file mode 100644 index 9f29185cdbf..00000000000 --- a/codegen/sdk/aws-shapes/shapes.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "smithy": "2.0", - "shapes": { - "aws.test#AwsVendorParams": { - "type": "structure", - "members": {} - } - } -} From 58ce71797d89a47e93daaac89392b60707bc4445 Mon Sep 17 00:00:00 2001 From: 0marperez Date: Fri, 14 Nov 2025 15:16:23 -0500 Subject: [PATCH 26/31] change name function padTo32 --- .../kotlin/runtime/auth/credentials/LoginTokenProvider.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 1ac9461a983..74240aebf35 100644 --- 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 @@ -259,13 +259,13 @@ private fun parseECKeyPem(pem: String): ECKeyData { 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() + val x = der.copyOfRange(publicKeyStart, publicKeyStart + coordLen).padOrTrimTo32() + val y = der.copyOfRange(publicKeyStart + coordLen, publicKeyStart + 2 * coordLen).padOrTrimTo32() return ECKeyData(d, x, y) } -private fun ByteArray.padTo32(): ByteArray = +private fun ByteArray.padOrTrimTo32(): ByteArray = if (size >= 32) { takeLast(32).toByteArray() } else { From de48bd50dfb782345db08a1fdf581dbd2e9f00e4 Mon Sep 17 00:00:00 2001 From: 0marperez Date: Fri, 14 Nov 2025 15:35:23 -0500 Subject: [PATCH 27/31] fix xy coord parsing --- .../runtime/auth/credentials/LoginTokenProvider.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 index 74240aebf35..7794002bcfa 100644 --- 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 @@ -233,7 +233,7 @@ internal data class ECKeyData( /** * Parses a PEM-encoded EC private key and extracts the private key scalar and public key (x, y) coordinates. - * Supports both "EC PRIVATE KEY" and "PRIVATE KEY" PEM formats for P-256 curve keys. + * 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-----", "") @@ -259,15 +259,15 @@ private fun parseECKeyPem(pem: String): ECKeyData { val remainingBytes = der.size - publicKeyStart val coordLen = remainingBytes / 2 - val x = der.copyOfRange(publicKeyStart, publicKeyStart + coordLen).padOrTrimTo32() - val y = der.copyOfRange(publicKeyStart + coordLen, publicKeyStart + 2 * coordLen).padOrTrimTo32() + 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.padOrTrimTo32(): ByteArray = - if (size >= 32) { - takeLast(32).toByteArray() +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 } From 73c081076fd828c65016e6976538733e0edbff99 Mon Sep 17 00:00:00 2001 From: 0marperez Date: Fri, 14 Nov 2025 16:17:05 -0500 Subject: [PATCH 28/31] delete model --- codegen/sdk/aws-models/sign-in.json | 1295 --------------------------- 1 file changed, 1295 deletions(-) delete mode 100644 codegen/sdk/aws-models/sign-in.json diff --git a/codegen/sdk/aws-models/sign-in.json b/codegen/sdk/aws-models/sign-in.json deleted file mode 100644 index 9ca07a4fe4b..00000000000 --- a/codegen/sdk/aws-models/sign-in.json +++ /dev/null @@ -1,1295 +0,0 @@ -{ - "smithy": "2.0", - "shapes": { - "com.amazonaws.signin#AccessDeniedException": { - "type": "structure", - "members": { - "error": { - "target": "com.amazonaws.signin#OAuth2ErrorCode", - "traits": { - "smithy.api#documentation": "OAuth 2.0 error code indicating the specific type of access denial\nCan be TOKEN_EXPIRED, AUTHCODE_EXPIRED, USER_CREDENTIALS_CHANGED, or INSUFFICIENT_PERMISSIONS", - "smithy.api#required": {} - } - }, - "message": { - "target": "smithy.api#String", - "traits": { - "smithy.api#documentation": "Detailed message explaining the access denial\nProvides specific information about why access was denied", - "smithy.api#required": {} - } - } - }, - "traits": { - "smithy.api#documentation": "Error thrown for access denied scenarios with flexible HTTP status mapping\n\nRuntime HTTP Status Code Mapping:\n- HTTP 401 (Unauthorized): TOKEN_EXPIRED, AUTHCODE_EXPIRED\n- HTTP 403 (Forbidden): USER_CREDENTIALS_CHANGED, INSUFFICIENT_PERMISSIONS\n\nThe specific HTTP status code is determined at runtime based on the error enum value.\nConsumers should use the error field to determine the specific access denial reason.", - "smithy.api#error": "client" - } - }, - "com.amazonaws.signin#AccessToken": { - "type": "structure", - "members": { - "accessKeyId": { - "target": "smithy.api#String", - "traits": { - "smithy.api#documentation": "AWS access key ID for temporary credentials", - "smithy.api#jsonName": "accessKeyId", - "smithy.api#required": {} - } - }, - "secretAccessKey": { - "target": "smithy.api#String", - "traits": { - "smithy.api#documentation": "AWS secret access key for temporary credentials", - "smithy.api#jsonName": "secretAccessKey", - "smithy.api#required": {} - } - }, - "sessionToken": { - "target": "smithy.api#String", - "traits": { - "smithy.api#documentation": "AWS session token for temporary credentials", - "smithy.api#jsonName": "sessionToken", - "smithy.api#required": {} - } - } - }, - "traits": { - "smithy.api#documentation": "AWS credentials structure containing temporary access credentials\n\nThe scoped-down, 15 minute duration AWS credentials.\nScoping down will be based on CLI policy (CLI team needs to create it).\nSimilar to cloud shell implementation.", - "smithy.api#sensitive": {} - } - }, - "com.amazonaws.signin#AuthorizationCode": { - "type": "string", - "traits": { - "smithy.api#documentation": "Authorization code received from AWS Sign-In /v1/authorize endpoint\n\nThe authorization code received from AWS Sign-In from /v1/authorize.\nUsed in auth code redemption flow only.", - "smithy.api#length": { - "min": 1, - "max": 512 - } - } - }, - "com.amazonaws.signin#ClientId": { - "type": "string", - "traits": { - "smithy.api#documentation": "Client identifier pattern for AWS Sign-In CLI clients\n\nThe ARN used by client as part of Sign-In onboarding. Expected values:\n- aws:signin:::cli/same-device (for CLI login on same device)\n- aws:signin:::cli/cross-device (for cross-device CLI login)\n- aws:signin:::cli/in-band (for in-band CLI login)\n- aws:signin:::cli/out-of-band (for out-of-band CLI login)\n\nThis will be finalized after consulting with UX as this is visible to end customer.", - "smithy.api#pattern": "^aws:signin:::cli/(same-device|cross-device|in-band|out-of-band)$" - } - }, - "com.amazonaws.signin#CodeVerifier": { - "type": "string", - "traits": { - "smithy.api#documentation": "PKCE code verifier for OAuth 2.0 security\n\nPKCE code verifier to prove possession of the original code challenge.\nUsed to prevent authorization code interception attacks in public clients.\nMust be 43-128 characters using unreserved characters [A-Z] / [a-z] / [0-9] / \"-\" / \".\" / \"_\" / \"~\"", - "smithy.api#length": { - "min": 43, - "max": 128 - }, - "smithy.api#pattern": "^[A-Za-z0-9\\-._~]+$" - } - }, - "com.amazonaws.signin#CreateOAuth2Token": { - "type": "operation", - "input": { - "target": "com.amazonaws.signin#CreateOAuth2TokenRequest" - }, - "output": { - "target": "com.amazonaws.signin#CreateOAuth2TokenResponse" - }, - "errors": [ - { - "target": "com.amazonaws.signin#AccessDeniedException" - }, - { - "target": "com.amazonaws.signin#InternalServerException" - }, - { - "target": "com.amazonaws.signin#TooManyRequestsError" - }, - { - "target": "com.amazonaws.signin#ValidationException" - } - ], - "traits": { - "smithy.api#auth": [], - "smithy.api#documentation": "CreateOAuth2Token API\n\nPath: /v1/token\nRequest Method: POST\nContent-Type: application/json or application/x-www-form-urlencoded\n\nThis API implements OAuth 2.0 flows for AWS Sign-In CLI clients, supporting both:\n1. Authorization code redemption (grant_type=authorization_code) - NOT idempotent\n2. Token refresh (grant_type=refresh_token) - Idempotent within token validity window\n\nThe operation behavior is determined by the grant_type parameter in the request body:\n\n**Authorization Code Flow (NOT Idempotent):**\n- JSON or form-encoded body with client_id, grant_type=authorization_code, code, redirect_uri, code_verifier\n- Returns access_token, token_type, expires_in, refresh_token, and id_token\n- Each authorization code can only be used ONCE for security (prevents replay attacks)\n\n**Token Refresh Flow (Idempotent):**\n- JSON or form-encoded body with client_id, grant_type=refresh_token, refresh_token\n- Returns access_token, token_type, expires_in, and refresh_token (no id_token)\n- Multiple calls with same refresh_token return consistent results within validity window\n\nAuthentication and authorization:\n- Confidential clients: sigv4 signing required with signin:ExchangeToken permissions\n- CLI clients (public): authn/authz skipped based on client_id & grant_type\n\nNote: This operation cannot be marked as @idempotent because it handles both idempotent\n(token refresh) and non-idempotent (auth code redemption) flows in a single endpoint.", - "smithy.api#http": { - "method": "POST", - "uri": "/v1/token" - }, - "smithy.test#smokeTests": [ - { - "id": "TokenOperationSmokeTest", - "params": { - "tokenInput": { - "clientId": "aws:signin:::cli/same-device", - "grantType": "authorization_code", - "code": "test-code", - "redirectUri": "https://example.com", - "codeVerifier": "test-code-verifier-1234567890abcdefghijklmnop" - } - }, - "vendorParamsShape": "aws.test#AwsVendorParams", - "vendorParams": {}, - "expect": { - "failure": {} - } - } - ] - } - }, - "com.amazonaws.signin#CreateOAuth2TokenRequest": { - "type": "structure", - "members": { - "tokenInput": { - "target": "com.amazonaws.signin#CreateOAuth2TokenRequestBody", - "traits": { - "smithy.api#documentation": "Flattened token operation inputs\nThe specific operation is determined by grant_type in the request body", - "smithy.api#httpPayload": {}, - "smithy.api#required": {} - } - } - }, - "traits": { - "smithy.api#documentation": "Input structure for CreateOAuth2Token operation\n\nContains flattened token operation inputs for both authorization code and refresh token flows.\nThe operation type is determined by the grant_type parameter in the request body.", - "smithy.api#input": {} - } - }, - "com.amazonaws.signin#CreateOAuth2TokenRequestBody": { - "type": "structure", - "members": { - "clientId": { - "target": "com.amazonaws.signin#ClientId", - "traits": { - "smithy.api#documentation": "The client identifier (ARN) used during Sign-In onboarding\nRequired for both authorization code and refresh token flows", - "smithy.api#jsonName": "clientId", - "smithy.api#required": {} - } - }, - "grantType": { - "target": "com.amazonaws.signin#GrantType", - "traits": { - "smithy.api#documentation": "OAuth 2.0 grant type - determines which flow is used\nMust be \"authorization_code\" or \"refresh_token\"", - "smithy.api#jsonName": "grantType", - "smithy.api#required": {} - } - }, - "code": { - "target": "com.amazonaws.signin#AuthorizationCode", - "traits": { - "smithy.api#documentation": "The authorization code received from /v1/authorize\nRequired only when grant_type=authorization_code" - } - }, - "redirectUri": { - "target": "com.amazonaws.signin#RedirectUri", - "traits": { - "smithy.api#documentation": "The redirect URI that must match the original authorization request\nRequired only when grant_type=authorization_code", - "smithy.api#jsonName": "redirectUri" - } - }, - "codeVerifier": { - "target": "com.amazonaws.signin#CodeVerifier", - "traits": { - "smithy.api#documentation": "PKCE code verifier to prove possession of the original code challenge\nRequired only when grant_type=authorization_code", - "smithy.api#jsonName": "codeVerifier" - } - }, - "refreshToken": { - "target": "com.amazonaws.signin#RefreshToken", - "traits": { - "smithy.api#documentation": "The refresh token returned from auth_code redemption\nRequired only when grant_type=refresh_token", - "smithy.api#jsonName": "refreshToken" - } - } - }, - "traits": { - "smithy.api#documentation": "Request body payload for CreateOAuth2Token operation\n\nThe operation type is determined by the grant_type parameter:\n- grant_type=authorization_code: Requires code, redirect_uri, code_verifier\n- grant_type=refresh_token: Requires refresh_token" - } - }, - "com.amazonaws.signin#CreateOAuth2TokenResponse": { - "type": "structure", - "members": { - "tokenOutput": { - "target": "com.amazonaws.signin#CreateOAuth2TokenResponseBody", - "traits": { - "smithy.api#documentation": "Flattened token operation outputs\nThe specific response fields depend on the grant_type used in the request", - "smithy.api#httpPayload": {}, - "smithy.api#required": {} - } - } - }, - "traits": { - "smithy.api#documentation": "Output structure for CreateOAuth2Token operation\n\nContains flattened token operation outputs for both authorization code and refresh token flows.\nThe response content depends on the grant_type from the original request.", - "smithy.api#output": {} - } - }, - "com.amazonaws.signin#CreateOAuth2TokenResponseBody": { - "type": "structure", - "members": { - "accessToken": { - "target": "com.amazonaws.signin#AccessToken", - "traits": { - "smithy.api#documentation": "Scoped-down AWS credentials (15 minute duration)\nPresent for both authorization code redemption and token refresh", - "smithy.api#jsonName": "accessToken", - "smithy.api#required": {} - } - }, - "tokenType": { - "target": "com.amazonaws.signin#TokenType", - "traits": { - "smithy.api#documentation": "Token type indicating this is AWS SigV4 credentials\nValue is \"aws_sigv4\" for both flows", - "smithy.api#jsonName": "tokenType", - "smithy.api#required": {} - } - }, - "expiresIn": { - "target": "com.amazonaws.signin#ExpiresIn", - "traits": { - "smithy.api#documentation": "Time to expiry in seconds (maximum 900)\nPresent for both authorization code redemption and token refresh", - "smithy.api#jsonName": "expiresIn", - "smithy.api#required": {} - } - }, - "refreshToken": { - "target": "com.amazonaws.signin#RefreshToken", - "traits": { - "smithy.api#documentation": "Encrypted refresh token with cnf.jkt (SHA-256 thumbprint of presented jwk)\nAlways present in responses (required for both flows)", - "smithy.api#jsonName": "refreshToken", - "smithy.api#required": {} - } - }, - "idToken": { - "target": "com.amazonaws.signin#IdToken", - "traits": { - "smithy.api#documentation": "ID token containing user identity information\nPresent only in authorization code redemption response (grant_type=authorization_code)\nNot included in token refresh responses", - "smithy.api#jsonName": "idToken" - } - } - }, - "traits": { - "smithy.api#documentation": "Response body payload for CreateOAuth2Token operation\n\nThe response content depends on the grant_type from the request:\n- grant_type=authorization_code: Returns all fields including refresh_token and id_token\n- grant_type=refresh_token: Returns access_token, token_type, expires_in, refresh_token (no id_token)" - } - }, - "com.amazonaws.signin#ExpiresIn": { - "type": "integer", - "traits": { - "smithy.api#documentation": "Time to expiry in seconds\n\nThe time to expiry in seconds, for these purposes will be at most 900 (15 minutes).", - "smithy.api#range": { - "min": 1, - "max": 900 - } - } - }, - "com.amazonaws.signin#GrantType": { - "type": "string", - "traits": { - "smithy.api#documentation": "OAuth 2.0 grant type parameter\n\nFor auth code redemption: Must be \"authorization_code\"\nFor token refresh: Must be \"refresh_token\"\n\nBased on client_id & grant_type, authn/authz is skipped for CLI endpoints.", - "smithy.api#pattern": "^(authorization_code|refresh_token)$" - } - }, - "com.amazonaws.signin#IdToken": { - "type": "string", - "traits": { - "smithy.api#documentation": "ID token containing user identity information\n\nEncoded JWT token containing user identity claims and authentication context.\nReturned only in authorization code redemption responses (grant_type=authorization_code).\nContains user identity information such as ARN and other identity claims.", - "smithy.api#length": { - "min": 1, - "max": 4096 - } - } - }, - "com.amazonaws.signin#InternalServerException": { - "type": "structure", - "members": { - "error": { - "target": "com.amazonaws.signin#OAuth2ErrorCode", - "traits": { - "smithy.api#documentation": "OAuth 2.0 error code indicating server error\nWill be SERVER_ERROR for internal server errors", - "smithy.api#required": {} - } - }, - "message": { - "target": "smithy.api#String", - "traits": { - "smithy.api#documentation": "Detailed message explaining the server error\nMay include error details for debugging purposes", - "smithy.api#required": {} - } - } - }, - "traits": { - "smithy.api#documentation": "Error thrown when an internal server error occurs\n\nHTTP Status Code: 500 Internal Server Error\n\nUsed for unexpected server-side errors that prevent request processing.", - "smithy.api#error": "server", - "smithy.api#httpError": 500 - } - }, - "com.amazonaws.signin#OAuth2ErrorCode": { - "type": "enum", - "members": { - "TOKEN_EXPIRED": { - "target": "smithy.api#Unit", - "traits": { - "smithy.api#documentation": "Token has expired and needs to be refreshed", - "smithy.api#enumValue": "TOKEN_EXPIRED" - } - }, - "USER_CREDENTIALS_CHANGED": { - "target": "smithy.api#Unit", - "traits": { - "smithy.api#documentation": "User credentials have been changed", - "smithy.api#enumValue": "USER_CREDENTIALS_CHANGED" - } - }, - "INSUFFICIENT_PERMISSIONS": { - "target": "smithy.api#Unit", - "traits": { - "smithy.api#documentation": "Insufficient permissions to perform this operation", - "smithy.api#enumValue": "INSUFFICIENT_PERMISSIONS" - } - }, - "AUTHCODE_EXPIRED": { - "target": "smithy.api#Unit", - "traits": { - "smithy.api#documentation": "Authorization code has expired", - "smithy.api#enumValue": "AUTHCODE_EXPIRED" - } - }, - "SERVER_ERROR": { - "target": "smithy.api#Unit", - "traits": { - "smithy.api#documentation": "Internal server error occurred", - "smithy.api#enumValue": "server_error" - } - }, - "INVALID_REQUEST": { - "target": "smithy.api#Unit", - "traits": { - "smithy.api#documentation": "The request is missing a required parameter, includes an invalid parameter value, or is otherwise malformed", - "smithy.api#enumValue": "INVALID_REQUEST" - } - } - }, - "traits": { - "smithy.api#documentation": "OAuth 2.0 error codes returned by the server\n\nStandard OAuth 2.0 error codes used in error responses to indicate\nthe specific type of error that occurred during token operations." - } - }, - "com.amazonaws.signin#RedirectUri": { - "type": "string", - "traits": { - "smithy.api#documentation": "Redirect URI for OAuth 2.0 flow validation\n\nThe same redirect URI used in the authorization request. This must match exactly\nwhat was sent in the original authorization request for security validation.", - "smithy.api#length": { - "min": 1, - "max": 2048 - } - } - }, - "com.amazonaws.signin#RefreshToken": { - "type": "string", - "traits": { - "smithy.api#documentation": "Encrypted refresh token with cnf.jkt\n\nThis is the encrypted refresh token returned from auth code redemption.\nThe token content includes cnf.jkt (SHA-256 thumbprint of the presented jwk).\nUsed in subsequent token refresh requests.", - "smithy.api#length": { - "min": 1, - "max": 2048 - }, - "smithy.api#sensitive": {} - } - }, - "com.amazonaws.signin#Signin": { - "type": "service", - "version": "2023-01-01", - "operations": [ - { - "target": "com.amazonaws.signin#CreateOAuth2Token" - } - ], - "traits": { - "aws.api#service": { - "sdkId": "Signin", - "arnNamespace": "signin", - "endpointPrefix": "signin" - }, - "aws.auth#sigv4": { - "name": "signin" - }, - "aws.endpoints#standardRegionalEndpoints": { - "partitionSpecialCases": { - "aws": [ - { - "endpoint": "https://{region}.signin.aws.amazon.com" - } - ], - "aws-cn": [ - { - "endpoint": "https://{region}.signin.amazonaws.cn" - } - ], - "aws-us-gov": [ - { - "endpoint": "https://{region}.signin.amazonaws-us-gov.com" - } - ] - } - }, - "aws.protocols#restJson1": {}, - "smithy.api#auth": [ - "aws.auth#sigv4" - ], - "smithy.api#documentation": "AWS Sign-In Data Plane Service\n\nThis service implements OAuth 2.0 flows for AWS CLI authentication,\nproviding secure token exchange and refresh capabilities.", - "smithy.api#title": "AWS Sign-In Data Plane", - "smithy.rules#endpointRuleSet": { - "version": "1.0", - "parameters": { - "UseDualStack": { - "builtIn": "AWS::UseDualStack", - "required": true, - "default": false, - "documentation": "When true, use the dual-stack endpoint. If the configured endpoint does not support dual-stack, dispatching the request MAY return an error.", - "type": "boolean" - }, - "UseFIPS": { - "builtIn": "AWS::UseFIPS", - "required": true, - "default": false, - "documentation": "When true, send this request to the FIPS-compliant regional endpoint. If the configured endpoint does not have a FIPS compliant endpoint, dispatching the request will return an error.", - "type": "boolean" - }, - "Endpoint": { - "builtIn": "SDK::Endpoint", - "required": false, - "documentation": "Override the endpoint used to send this request", - "type": "string" - }, - "Region": { - "builtIn": "AWS::Region", - "required": false, - "documentation": "The AWS region used to dispatch the request.", - "type": "string" - } - }, - "rules": [ - { - "conditions": [ - { - "fn": "isSet", - "argv": [ - { - "ref": "Endpoint" - } - ] - } - ], - "rules": [ - { - "conditions": [ - { - "fn": "booleanEquals", - "argv": [ - { - "ref": "UseFIPS" - }, - true - ] - } - ], - "error": "Invalid Configuration: FIPS and custom endpoint are not supported", - "type": "error" - }, - { - "conditions": [], - "rules": [ - { - "conditions": [ - { - "fn": "booleanEquals", - "argv": [ - { - "ref": "UseDualStack" - }, - true - ] - } - ], - "error": "Invalid Configuration: Dualstack and custom endpoint are not supported", - "type": "error" - }, - { - "conditions": [], - "endpoint": { - "url": { - "ref": "Endpoint" - }, - "properties": {}, - "headers": {} - }, - "type": "endpoint" - } - ], - "type": "tree" - } - ], - "type": "tree" - }, - { - "conditions": [], - "rules": [ - { - "conditions": [ - { - "fn": "isSet", - "argv": [ - { - "ref": "Region" - } - ] - } - ], - "rules": [ - { - "conditions": [ - { - "fn": "aws.partition", - "argv": [ - { - "ref": "Region" - } - ], - "assign": "PartitionResult" - } - ], - "rules": [ - { - "conditions": [ - { - "fn": "stringEquals", - "argv": [ - { - "fn": "getAttr", - "argv": [ - { - "ref": "PartitionResult" - }, - "name" - ] - }, - "aws" - ] - }, - { - "fn": "booleanEquals", - "argv": [ - { - "ref": "UseFIPS" - }, - false - ] - }, - { - "fn": "booleanEquals", - "argv": [ - { - "ref": "UseDualStack" - }, - false - ] - } - ], - "endpoint": { - "url": "https://{Region}.signin.aws.amazon.com", - "properties": {}, - "headers": {} - }, - "type": "endpoint" - }, - { - "conditions": [ - { - "fn": "stringEquals", - "argv": [ - { - "fn": "getAttr", - "argv": [ - { - "ref": "PartitionResult" - }, - "name" - ] - }, - "aws-cn" - ] - }, - { - "fn": "booleanEquals", - "argv": [ - { - "ref": "UseFIPS" - }, - false - ] - }, - { - "fn": "booleanEquals", - "argv": [ - { - "ref": "UseDualStack" - }, - false - ] - } - ], - "endpoint": { - "url": "https://{Region}.signin.amazonaws.cn", - "properties": {}, - "headers": {} - }, - "type": "endpoint" - }, - { - "conditions": [ - { - "fn": "stringEquals", - "argv": [ - { - "fn": "getAttr", - "argv": [ - { - "ref": "PartitionResult" - }, - "name" - ] - }, - "aws-us-gov" - ] - }, - { - "fn": "booleanEquals", - "argv": [ - { - "ref": "UseFIPS" - }, - false - ] - }, - { - "fn": "booleanEquals", - "argv": [ - { - "ref": "UseDualStack" - }, - false - ] - } - ], - "endpoint": { - "url": "https://{Region}.signin.amazonaws-us-gov.com", - "properties": {}, - "headers": {} - }, - "type": "endpoint" - }, - { - "conditions": [ - { - "fn": "booleanEquals", - "argv": [ - { - "ref": "UseFIPS" - }, - true - ] - }, - { - "fn": "booleanEquals", - "argv": [ - { - "ref": "UseDualStack" - }, - true - ] - } - ], - "rules": [ - { - "conditions": [ - { - "fn": "booleanEquals", - "argv": [ - true, - { - "fn": "getAttr", - "argv": [ - { - "ref": "PartitionResult" - }, - "supportsFIPS" - ] - } - ] - }, - { - "fn": "booleanEquals", - "argv": [ - true, - { - "fn": "getAttr", - "argv": [ - { - "ref": "PartitionResult" - }, - "supportsDualStack" - ] - } - ] - } - ], - "rules": [ - { - "conditions": [], - "endpoint": { - "url": "https://signin-fips.{Region}.{PartitionResult#dualStackDnsSuffix}", - "properties": {}, - "headers": {} - }, - "type": "endpoint" - } - ], - "type": "tree" - }, - { - "conditions": [], - "error": "FIPS and DualStack are enabled, but this partition does not support one or both", - "type": "error" - } - ], - "type": "tree" - }, - { - "conditions": [ - { - "fn": "booleanEquals", - "argv": [ - { - "ref": "UseFIPS" - }, - true - ] - }, - { - "fn": "booleanEquals", - "argv": [ - { - "ref": "UseDualStack" - }, - false - ] - } - ], - "rules": [ - { - "conditions": [ - { - "fn": "booleanEquals", - "argv": [ - { - "fn": "getAttr", - "argv": [ - { - "ref": "PartitionResult" - }, - "supportsFIPS" - ] - }, - true - ] - } - ], - "rules": [ - { - "conditions": [], - "endpoint": { - "url": "https://signin-fips.{Region}.{PartitionResult#dnsSuffix}", - "properties": {}, - "headers": {} - }, - "type": "endpoint" - } - ], - "type": "tree" - }, - { - "conditions": [], - "error": "FIPS is enabled but this partition does not support FIPS", - "type": "error" - } - ], - "type": "tree" - }, - { - "conditions": [ - { - "fn": "booleanEquals", - "argv": [ - { - "ref": "UseFIPS" - }, - false - ] - }, - { - "fn": "booleanEquals", - "argv": [ - { - "ref": "UseDualStack" - }, - true - ] - } - ], - "rules": [ - { - "conditions": [ - { - "fn": "booleanEquals", - "argv": [ - true, - { - "fn": "getAttr", - "argv": [ - { - "ref": "PartitionResult" - }, - "supportsDualStack" - ] - } - ] - } - ], - "rules": [ - { - "conditions": [], - "endpoint": { - "url": "https://signin.{Region}.{PartitionResult#dualStackDnsSuffix}", - "properties": {}, - "headers": {} - }, - "type": "endpoint" - } - ], - "type": "tree" - }, - { - "conditions": [], - "error": "DualStack is enabled but this partition does not support DualStack", - "type": "error" - } - ], - "type": "tree" - }, - { - "conditions": [], - "endpoint": { - "url": "https://signin.{Region}.{PartitionResult#dnsSuffix}", - "properties": {}, - "headers": {} - }, - "type": "endpoint" - } - ], - "type": "tree" - } - ], - "type": "tree" - }, - { - "conditions": [], - "error": "Invalid Configuration: Missing Region", - "type": "error" - } - ], - "type": "tree" - } - ] - }, - "smithy.rules#endpointTests": { - "testCases": [ - { - "documentation": "For custom endpoint with region not set and fips disabled", - "expect": { - "endpoint": { - "url": "https://example.com" - } - }, - "params": { - "Endpoint": "https://example.com", - "UseFIPS": false - } - }, - { - "documentation": "For custom endpoint with fips enabled", - "expect": { - "error": "Invalid Configuration: FIPS and custom endpoint are not supported" - }, - "params": { - "Endpoint": "https://example.com", - "UseFIPS": true - } - }, - { - "documentation": "For custom endpoint with fips disabled and dualstack enabled", - "expect": { - "error": "Invalid Configuration: Dualstack and custom endpoint are not supported" - }, - "params": { - "Endpoint": "https://example.com", - "UseFIPS": false, - "UseDualStack": true - } - }, - { - "documentation": "For region us-east-1 with FIPS enabled and DualStack enabled", - "expect": { - "endpoint": { - "url": "https://signin-fips.us-east-1.api.aws" - } - }, - "params": { - "Region": "us-east-1", - "UseFIPS": true, - "UseDualStack": true - } - }, - { - "documentation": "For region us-east-1 with FIPS enabled and DualStack disabled", - "expect": { - "endpoint": { - "url": "https://signin-fips.us-east-1.amazonaws.com" - } - }, - "params": { - "Region": "us-east-1", - "UseFIPS": true, - "UseDualStack": false - } - }, - { - "documentation": "For region us-east-1 with FIPS disabled and DualStack enabled", - "expect": { - "endpoint": { - "url": "https://signin.us-east-1.api.aws" - } - }, - "params": { - "Region": "us-east-1", - "UseFIPS": false, - "UseDualStack": true - } - }, - { - "documentation": "For region us-east-1 with FIPS disabled and DualStack disabled", - "expect": { - "endpoint": { - "url": "https://us-east-1.signin.aws.amazon.com" - } - }, - "params": { - "Region": "us-east-1", - "UseFIPS": false, - "UseDualStack": false - } - }, - { - "documentation": "For region cn-northwest-1 with FIPS enabled and DualStack enabled", - "expect": { - "endpoint": { - "url": "https://signin-fips.cn-northwest-1.api.amazonwebservices.com.cn" - } - }, - "params": { - "Region": "cn-northwest-1", - "UseFIPS": true, - "UseDualStack": true - } - }, - { - "documentation": "For region cn-northwest-1 with FIPS enabled and DualStack disabled", - "expect": { - "endpoint": { - "url": "https://signin-fips.cn-northwest-1.amazonaws.com.cn" - } - }, - "params": { - "Region": "cn-northwest-1", - "UseFIPS": true, - "UseDualStack": false - } - }, - { - "documentation": "For region cn-northwest-1 with FIPS disabled and DualStack enabled", - "expect": { - "endpoint": { - "url": "https://signin.cn-northwest-1.api.amazonwebservices.com.cn" - } - }, - "params": { - "Region": "cn-northwest-1", - "UseFIPS": false, - "UseDualStack": true - } - }, - { - "documentation": "For region cn-northwest-1 with FIPS disabled and DualStack disabled", - "expect": { - "endpoint": { - "url": "https://cn-northwest-1.signin.amazonaws.cn" - } - }, - "params": { - "Region": "cn-northwest-1", - "UseFIPS": false, - "UseDualStack": false - } - }, - { - "documentation": "For region eusc-de-east-1 with FIPS enabled and DualStack disabled", - "expect": { - "endpoint": { - "url": "https://signin-fips.eusc-de-east-1.amazonaws.eu" - } - }, - "params": { - "Region": "eusc-de-east-1", - "UseFIPS": true, - "UseDualStack": false - } - }, - { - "documentation": "For region eusc-de-east-1 with FIPS disabled and DualStack disabled", - "expect": { - "endpoint": { - "url": "https://signin.eusc-de-east-1.amazonaws.eu" - } - }, - "params": { - "Region": "eusc-de-east-1", - "UseFIPS": false, - "UseDualStack": false - } - }, - { - "documentation": "For region us-iso-east-1 with FIPS enabled and DualStack disabled", - "expect": { - "endpoint": { - "url": "https://signin-fips.us-iso-east-1.c2s.ic.gov" - } - }, - "params": { - "Region": "us-iso-east-1", - "UseFIPS": true, - "UseDualStack": false - } - }, - { - "documentation": "For region us-iso-east-1 with FIPS disabled and DualStack disabled", - "expect": { - "endpoint": { - "url": "https://signin.us-iso-east-1.c2s.ic.gov" - } - }, - "params": { - "Region": "us-iso-east-1", - "UseFIPS": false, - "UseDualStack": false - } - }, - { - "documentation": "For region us-isob-east-1 with FIPS enabled and DualStack disabled", - "expect": { - "endpoint": { - "url": "https://signin-fips.us-isob-east-1.sc2s.sgov.gov" - } - }, - "params": { - "Region": "us-isob-east-1", - "UseFIPS": true, - "UseDualStack": false - } - }, - { - "documentation": "For region us-isob-east-1 with FIPS disabled and DualStack disabled", - "expect": { - "endpoint": { - "url": "https://signin.us-isob-east-1.sc2s.sgov.gov" - } - }, - "params": { - "Region": "us-isob-east-1", - "UseFIPS": false, - "UseDualStack": false - } - }, - { - "documentation": "For region eu-isoe-west-1 with FIPS enabled and DualStack disabled", - "expect": { - "endpoint": { - "url": "https://signin-fips.eu-isoe-west-1.cloud.adc-e.uk" - } - }, - "params": { - "Region": "eu-isoe-west-1", - "UseFIPS": true, - "UseDualStack": false - } - }, - { - "documentation": "For region eu-isoe-west-1 with FIPS disabled and DualStack disabled", - "expect": { - "endpoint": { - "url": "https://signin.eu-isoe-west-1.cloud.adc-e.uk" - } - }, - "params": { - "Region": "eu-isoe-west-1", - "UseFIPS": false, - "UseDualStack": false - } - }, - { - "documentation": "For region us-isof-south-1 with FIPS enabled and DualStack disabled", - "expect": { - "endpoint": { - "url": "https://signin-fips.us-isof-south-1.csp.hci.ic.gov" - } - }, - "params": { - "Region": "us-isof-south-1", - "UseFIPS": true, - "UseDualStack": false - } - }, - { - "documentation": "For region us-isof-south-1 with FIPS disabled and DualStack disabled", - "expect": { - "endpoint": { - "url": "https://signin.us-isof-south-1.csp.hci.ic.gov" - } - }, - "params": { - "Region": "us-isof-south-1", - "UseFIPS": false, - "UseDualStack": false - } - }, - { - "documentation": "For region us-gov-west-1 with FIPS enabled and DualStack enabled", - "expect": { - "endpoint": { - "url": "https://signin-fips.us-gov-west-1.api.aws" - } - }, - "params": { - "Region": "us-gov-west-1", - "UseFIPS": true, - "UseDualStack": true - } - }, - { - "documentation": "For region us-gov-west-1 with FIPS enabled and DualStack disabled", - "expect": { - "endpoint": { - "url": "https://signin-fips.us-gov-west-1.amazonaws.com" - } - }, - "params": { - "Region": "us-gov-west-1", - "UseFIPS": true, - "UseDualStack": false - } - }, - { - "documentation": "For region us-gov-west-1 with FIPS disabled and DualStack enabled", - "expect": { - "endpoint": { - "url": "https://signin.us-gov-west-1.api.aws" - } - }, - "params": { - "Region": "us-gov-west-1", - "UseFIPS": false, - "UseDualStack": true - } - }, - { - "documentation": "For region us-gov-west-1 with FIPS disabled and DualStack disabled", - "expect": { - "endpoint": { - "url": "https://us-gov-west-1.signin.amazonaws-us-gov.com" - } - }, - "params": { - "Region": "us-gov-west-1", - "UseFIPS": false, - "UseDualStack": false - } - }, - { - "documentation": "Missing region", - "expect": { - "error": "Invalid Configuration: Missing Region" - } - } - ], - "version": "1.0" - } - } - }, - "com.amazonaws.signin#TokenType": { - "type": "string", - "traits": { - "smithy.api#documentation": "Token type parameter indicating credential usage\n\nA parameter which indicates to the client how the token must be used.\nValue is \"aws_sigv4\" (instead of typical \"Bearer\" for other OAuth systems)\nto indicate that the client must de-serialize the token and use it to generate a signature.", - "smithy.api#pattern": "^aws_sigv4$" - } - }, - "com.amazonaws.signin#TooManyRequestsError": { - "type": "structure", - "members": { - "error": { - "target": "com.amazonaws.signin#OAuth2ErrorCode", - "traits": { - "smithy.api#documentation": "OAuth 2.0 error code indicating the specific type of error\nWill be INVALID_REQUEST for rate limiting scenarios", - "smithy.api#required": {} - } - }, - "message": { - "target": "smithy.api#String", - "traits": { - "smithy.api#documentation": "Detailed message about the rate limiting\nMay include retry-after information or rate limit details", - "smithy.api#required": {} - } - } - }, - "traits": { - "smithy.api#documentation": "Error thrown when rate limit is exceeded\n\nHTTP Status Code: 429 Too Many Requests\n\nPossible OAuth2ErrorCode values:\n- INVALID_REQUEST: Rate limiting, too many requests, abuse prevention\n\nPossible causes:\n- Too many token requests from the same client\n- Rate limiting based on client_id or IP address\n- Abuse prevention mechanisms triggered\n- Service protection against excessive token generation", - "smithy.api#error": "client", - "smithy.api#httpError": 429 - } - }, - "com.amazonaws.signin#ValidationException": { - "type": "structure", - "members": { - "error": { - "target": "com.amazonaws.signin#OAuth2ErrorCode", - "traits": { - "smithy.api#documentation": "OAuth 2.0 error code indicating validation failure\nWill be INVALID_REQUEST for validation errors", - "smithy.api#required": {} - } - }, - "message": { - "target": "smithy.api#String", - "traits": { - "smithy.api#documentation": "Detailed message explaining the validation failure\nProvides specific information about which validation failed", - "smithy.api#required": {} - } - } - }, - "traits": { - "smithy.api#documentation": "Error thrown when request validation fails\n\nHTTP Status Code: 400 Bad Request\n\nUsed for request validation errors such as malformed parameters,\nmissing required fields, or invalid parameter values.", - "smithy.api#error": "client", - "smithy.api#httpError": 400 - } - } - } -} \ No newline at end of file From 244989cc87e28ae45ac687ff7127dddc4fada69e Mon Sep 17 00:00:00 2001 From: 0marperez Date: Wed, 19 Nov 2025 15:15:28 -0500 Subject: [PATCH 29/31] fix model name --- aws-runtime/aws-config/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aws-runtime/aws-config/build.gradle.kts b/aws-runtime/aws-config/build.gradle.kts index 5b13108c133..2e9506af3a3 100644 --- a/aws-runtime/aws-config/build.gradle.kts +++ b/aws-runtime/aws-config/build.gradle.kts @@ -192,7 +192,7 @@ smithyBuild { create("signin-credentials-provider") { imports = listOf( - awsModelFile("sign-in.json"), + awsModelFile("signin.json"), ) val serviceShape = "com.amazonaws.signin#Signin" From e5aa9611952bc11912ed2ca4644a0113f0236122 Mon Sep 17 00:00:00 2001 From: 0marperez Date: Wed, 19 Nov 2025 15:55:03 -0500 Subject: [PATCH 30/31] try fix tests --- .../kotlin/runtime/auth/credentials/LoginTokenProviderTest.kt | 1 + 1 file changed, 1 insertion(+) 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 index 98da76189ad..5a22778d41f 100644 --- 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 @@ -148,6 +148,7 @@ class LoginTokenProviderTest { val tokenProvider = LoginTokenProvider( loginSessionName = loginSessionName, + region = "us-west-2", refreshBufferWindow = 0.seconds, httpClient = httpClient, platformProvider = testPlatform, From dbb9fb6e6b2faf1ae7867ba90d9d1fbfb1a68de1 Mon Sep 17 00:00:00 2001 From: 0marperez Date: Wed, 19 Nov 2025 16:21:33 -0500 Subject: [PATCH 31/31] try fix test again --- .../kotlin/runtime/auth/credentials/LoginTokenProviderTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 5a22778d41f..9a175d4b276 100644 --- 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 @@ -154,7 +154,7 @@ class LoginTokenProviderTest { platformProvider = testPlatform, clock = testClock, cacheDirectory = resolveCacheDir(testPlatform), - client = signinClient(providedHttpClient = httpClient), + client = signinClient("us-west-2", providedHttpClient = httpClient), ) testCase.outcomes.forEach { expectedOutcome ->