diff --git a/.changes/6de10487-c3a0-4c63-929a-ba11a415ea8f.json b/.changes/6de10487-c3a0-4c63-929a-ba11a415ea8f.json new file mode 100644 index 000000000..3e4b61c35 --- /dev/null +++ b/.changes/6de10487-c3a0-4c63-929a-ba11a415ea8f.json @@ -0,0 +1,5 @@ +{ + "id": "6de10487-c3a0-4c63-929a-ba11a415ea8f", + "type": "feature", + "description": "Detect and automatically correct clock skew to prevent signing errors" +} \ No newline at end of file diff --git a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/RuntimeTypes.kt b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/RuntimeTypes.kt index 3f5a7dd12..b651041b3 100644 --- a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/RuntimeTypes.kt +++ b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/core/RuntimeTypes.kt @@ -366,6 +366,7 @@ object RuntimeTypes { val AwsQueryCompatibleErrorDetails = symbol("AwsQueryCompatibleErrorDetails") val setAwsQueryCompatibleErrorMetadata = symbol("setAwsQueryCompatibleErrorMetadata") val XAmznQueryErrorHeader = symbol("X_AMZN_QUERY_ERROR_HEADER") + val ClockSkewInterceptor = symbol("ClockSkewInterceptor") } object AwsJsonProtocols : RuntimeTypePackage(KotlinDependency.AWS_JSON_PROTOCOLS) { diff --git a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/ServiceClientGenerator.kt b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/ServiceClientGenerator.kt index 6151cbacc..531090bd1 100644 --- a/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/ServiceClientGenerator.kt +++ b/codegen/smithy-kotlin-codegen/src/main/kotlin/software/amazon/smithy/kotlin/codegen/rendering/ServiceClientGenerator.kt @@ -57,6 +57,11 @@ class ServiceClientGenerator(private val ctx: RenderingContext) { */ val RenderingContext: SectionKey> = SectionKey("RenderingContext") } + + /** + * [SectionId] used when rendering the finalizeConfig block of a service client + */ + object FinalizeConfig : SectionId } init { diff --git a/runtime/auth/http-auth-aws/common/src/aws/smithy/kotlin/runtime/http/auth/AwsHttpSigner.kt b/runtime/auth/http-auth-aws/common/src/aws/smithy/kotlin/runtime/http/auth/AwsHttpSigner.kt index 509505747..ae4123c3f 100644 --- a/runtime/auth/http-auth-aws/common/src/aws/smithy/kotlin/runtime/http/auth/AwsHttpSigner.kt +++ b/runtime/auth/http-auth-aws/common/src/aws/smithy/kotlin/runtime/http/auth/AwsHttpSigner.kt @@ -12,8 +12,10 @@ import aws.smithy.kotlin.runtime.auth.awssigning.internal.setAwsChunkedBody import aws.smithy.kotlin.runtime.auth.awssigning.internal.setAwsChunkedHeaders import aws.smithy.kotlin.runtime.auth.awssigning.internal.useAwsChunkedEncoding import aws.smithy.kotlin.runtime.http.HttpBody +import aws.smithy.kotlin.runtime.http.operation.HttpOperationContext import aws.smithy.kotlin.runtime.http.request.HttpRequest import aws.smithy.kotlin.runtime.http.request.HttpRequestBuilder +import aws.smithy.kotlin.runtime.time.Instant import aws.smithy.kotlin.runtime.util.get import kotlin.time.Duration @@ -123,7 +125,10 @@ public class AwsHttpSigner(private val config: Config) : HttpSigner { service = attributes.getOrNull(AwsSigningAttributes.SigningService) ?: checkNotNull(config.service) credentials = signingRequest.identity as Credentials algorithm = config.algorithm + + // apply clock skew if applicable signingDate = attributes.getOrNull(AwsSigningAttributes.SigningDate) + ?: (Instant.now() + (attributes.getOrNull(HttpOperationContext.ClockSkew) ?: Duration.ZERO)) signatureType = config.signatureType omitSessionToken = config.omitSessionToken diff --git a/runtime/protocol/aws-protocol-core/api/aws-protocol-core.api b/runtime/protocol/aws-protocol-core/api/aws-protocol-core.api index c6b984481..6da1ee55a 100644 --- a/runtime/protocol/aws-protocol-core/api/aws-protocol-core.api +++ b/runtime/protocol/aws-protocol-core/api/aws-protocol-core.api @@ -11,6 +11,34 @@ public final class aws/smithy/kotlin/runtime/awsprotocol/AwsQueryCompatibleError public final class aws/smithy/kotlin/runtime/awsprotocol/AwsQueryCompatibleErrorDetailsKt { } +public final class aws/smithy/kotlin/runtime/awsprotocol/ClockSkewInterceptor : aws/smithy/kotlin/runtime/client/Interceptor { + public static final field Companion Laws/smithy/kotlin/runtime/awsprotocol/ClockSkewInterceptor$Companion; + public fun ()V + public fun modifyBeforeAttemptCompletion-gIAlu-s (Laws/smithy/kotlin/runtime/client/ResponseInterceptorContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun modifyBeforeCompletion-gIAlu-s (Laws/smithy/kotlin/runtime/client/ResponseInterceptorContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun modifyBeforeDeserialization (Laws/smithy/kotlin/runtime/client/ProtocolResponseInterceptorContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun modifyBeforeRetryLoop (Laws/smithy/kotlin/runtime/client/ProtocolRequestInterceptorContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun modifyBeforeSerialization (Laws/smithy/kotlin/runtime/client/RequestInterceptorContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun modifyBeforeSigning (Laws/smithy/kotlin/runtime/client/ProtocolRequestInterceptorContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun modifyBeforeTransmit (Laws/smithy/kotlin/runtime/client/ProtocolRequestInterceptorContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun readAfterAttempt (Laws/smithy/kotlin/runtime/client/ResponseInterceptorContext;)V + public fun readAfterDeserialization (Laws/smithy/kotlin/runtime/client/ResponseInterceptorContext;)V + public fun readAfterExecution (Laws/smithy/kotlin/runtime/client/ResponseInterceptorContext;)V + public fun readAfterSerialization (Laws/smithy/kotlin/runtime/client/ProtocolRequestInterceptorContext;)V + public fun readAfterSigning (Laws/smithy/kotlin/runtime/client/ProtocolRequestInterceptorContext;)V + public fun readAfterTransmit (Laws/smithy/kotlin/runtime/client/ProtocolResponseInterceptorContext;)V + public fun readBeforeAttempt (Laws/smithy/kotlin/runtime/client/ProtocolRequestInterceptorContext;)V + public fun readBeforeDeserialization (Laws/smithy/kotlin/runtime/client/ProtocolResponseInterceptorContext;)V + public fun readBeforeExecution (Laws/smithy/kotlin/runtime/client/RequestInterceptorContext;)V + public fun readBeforeSerialization (Laws/smithy/kotlin/runtime/client/RequestInterceptorContext;)V + public fun readBeforeSigning (Laws/smithy/kotlin/runtime/client/ProtocolRequestInterceptorContext;)V + public fun readBeforeTransmit (Laws/smithy/kotlin/runtime/client/ProtocolRequestInterceptorContext;)V +} + +public final class aws/smithy/kotlin/runtime/awsprotocol/ClockSkewInterceptor$Companion { + public final fun getCLOCK_SKEW_THRESHOLD-UwyO8pc ()J +} + public final class aws/smithy/kotlin/runtime/awsprotocol/ProtocolErrorsKt { } diff --git a/runtime/protocol/aws-protocol-core/build.gradle.kts b/runtime/protocol/aws-protocol-core/build.gradle.kts index 638b3f9a4..c6ab376e8 100644 --- a/runtime/protocol/aws-protocol-core/build.gradle.kts +++ b/runtime/protocol/aws-protocol-core/build.gradle.kts @@ -9,12 +9,24 @@ extra["moduleName"] = "aws.smithy.kotlin.runtime.awsprotocol" val coroutinesVersion: String by project +apply(plugin = "kotlinx-atomicfu") + kotlin { sourceSets { commonMain { dependencies { api(project(":runtime:protocol:http")) api(project(":runtime:runtime-core")) + api(project(":runtime:protocol:http-client")) + api(project(":runtime:smithy-client")) + implementation(libs.kotlinx.atomicfu) + } + } + + commonTest { + dependencies { + implementation(libs.kotlinx.coroutines.test) + implementation(project(":runtime:protocol:http-test")) } } diff --git a/runtime/protocol/aws-protocol-core/common/src/aws/smithy/kotlin/runtime/awsprotocol/ClockSkewInterceptor.kt b/runtime/protocol/aws-protocol-core/common/src/aws/smithy/kotlin/runtime/awsprotocol/ClockSkewInterceptor.kt new file mode 100644 index 000000000..39f7391fd --- /dev/null +++ b/runtime/protocol/aws-protocol-core/common/src/aws/smithy/kotlin/runtime/awsprotocol/ClockSkewInterceptor.kt @@ -0,0 +1,118 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package aws.smithy.kotlin.runtime.awsprotocol + +import aws.smithy.kotlin.runtime.ErrorMetadata +import aws.smithy.kotlin.runtime.SdkBaseException +import aws.smithy.kotlin.runtime.ServiceErrorMetadata +import aws.smithy.kotlin.runtime.client.ProtocolRequestInterceptorContext +import aws.smithy.kotlin.runtime.client.ResponseInterceptorContext +import aws.smithy.kotlin.runtime.http.interceptors.HttpInterceptor +import aws.smithy.kotlin.runtime.http.operation.HttpOperationContext +import aws.smithy.kotlin.runtime.http.request.HttpRequest +import aws.smithy.kotlin.runtime.http.response.HttpResponse +import aws.smithy.kotlin.runtime.http.response.header +import aws.smithy.kotlin.runtime.telemetry.logging.logger +import aws.smithy.kotlin.runtime.time.Instant +import aws.smithy.kotlin.runtime.time.until +import aws.smithy.kotlin.runtime.util.get +import kotlinx.atomicfu.* +import kotlin.coroutines.coroutineContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes + +/** + * An interceptor used to detect clock skew (difference between client and server clocks) and apply a correction. + */ +public class ClockSkewInterceptor : HttpInterceptor { + public companion object { + /** + * How much must the clock be skewed before attempting correction + */ + public val CLOCK_SKEW_THRESHOLD: Duration = 4.minutes + + /** + * Determine whether the client's clock is skewed relative to the server. + * @return true if the service's response represents a definite clock skew error + * OR a *possible* clock skew error AND the skew exists. false otherwise. + * @param errorCode the server's error code + * @param serverTime the server's time + */ + internal fun Instant.isSkewed(serverTime: Instant, errorCode: String): Boolean = + CLOCK_SKEW_ERROR_CODES.contains(errorCode) || (POSSIBLE_CLOCK_SKEW_ERROR_CODES.contains(errorCode) && until(serverTime).absoluteValue >= CLOCK_SKEW_THRESHOLD) + + // Errors definitely caused by clock skew + private val CLOCK_SKEW_ERROR_CODES = listOf( + "RequestTimeTooSkewed", + "RequestExpired", + "RequestInTheFuture", + ) + + // Errors possibly caused by clock skew + private val POSSIBLE_CLOCK_SKEW_ERROR_CODES = listOf( + "InvalidSignatureException", + "SignatureDoesNotMatch", + "AuthFailure", + ) + } + + // Clock skew to be applied to all requests + private val _currentSkew: AtomicRef = atomic(null) + + /** + * Apply the previously-computed skew, if it's set, to the execution context before signing + */ + public override suspend fun modifyBeforeSigning(context: ProtocolRequestInterceptorContext): HttpRequest { + val logger = coroutineContext.logger() + + val skew = _currentSkew.value + skew?.let { + logger.info { "applying clock skew $skew to client" } + context.executionContext[HttpOperationContext.ClockSkew] = skew + } + + context.executionContext[HttpOperationContext.ClockSkewApproximateSigningTime] = Instant.now() + + return context.protocolRequest + } + + /** + * After receiving a response, check if the client clock is skewed and apply a correction if necessary. + */ + public override suspend fun modifyBeforeAttemptCompletion(context: ResponseInterceptorContext): Result { + val logger = coroutineContext.logger() + + val serverTime = context.protocolResponse?.header("Date")?.let { + Instant.fromRfc5322(it) + } ?: run { + logger.debug { "service did not return \"Date\" header, skipping skew calculation" } + return context.response + } + + val clientTime = context.protocolRequest.headers["Date"]?.let { + Instant.fromRfc5322(it) + } ?: context.protocolRequest.headers["x-amz-date"]?.let { + Instant.fromIso8601(it) + } ?: context.executionContext[HttpOperationContext.ClockSkewApproximateSigningTime] + + val ex = (context.response.exceptionOrNull() as? SdkBaseException) ?: return context.response + val errorCode = ex.sdkErrorMetadata.attributes.getOrNull(ServiceErrorMetadata.ErrorCode) + + errorCode?.let { + if (clientTime.isSkewed(serverTime, it)) { + val skew = clientTime.until(serverTime) + logger.warn { "client clock ($clientTime) is skewed $skew from the server ($serverTime), applying correction" } + _currentSkew.getAndSet(skew) + context.executionContext[HttpOperationContext.ClockSkew] = skew + + // Mark the exception as retryable + ex.sdkErrorMetadata.attributes[ErrorMetadata.Retryable] = true + return Result.failure(ex) + } + } + + return context.response + } +} diff --git a/runtime/protocol/aws-protocol-core/common/test/ClockSkewInterceptorTest.kt b/runtime/protocol/aws-protocol-core/common/test/ClockSkewInterceptorTest.kt new file mode 100644 index 000000000..4e67b12e1 --- /dev/null +++ b/runtime/protocol/aws-protocol-core/common/test/ClockSkewInterceptorTest.kt @@ -0,0 +1,198 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package aws.smithy.kotlin.runtime.awsprotocol + +import aws.smithy.kotlin.runtime.SdkBaseException +import aws.smithy.kotlin.runtime.ServiceErrorMetadata +import aws.smithy.kotlin.runtime.awsprotocol.ClockSkewInterceptor.Companion.CLOCK_SKEW_THRESHOLD +import aws.smithy.kotlin.runtime.awsprotocol.ClockSkewInterceptor.Companion.isSkewed +import aws.smithy.kotlin.runtime.client.ResponseInterceptorContext +import aws.smithy.kotlin.runtime.http.* +import aws.smithy.kotlin.runtime.http.interceptors.HttpInterceptor +import aws.smithy.kotlin.runtime.http.operation.HttpDeserialize +import aws.smithy.kotlin.runtime.http.operation.HttpOperationContext +import aws.smithy.kotlin.runtime.http.operation.HttpSerialize +import aws.smithy.kotlin.runtime.http.operation.SdkHttpOperation +import aws.smithy.kotlin.runtime.http.operation.roundTrip +import aws.smithy.kotlin.runtime.http.request.HttpRequest +import aws.smithy.kotlin.runtime.http.request.HttpRequestBuilder +import aws.smithy.kotlin.runtime.http.response.HttpResponse +import aws.smithy.kotlin.runtime.httptest.TestEngine +import aws.smithy.kotlin.runtime.io.SdkSource +import aws.smithy.kotlin.runtime.io.source +import aws.smithy.kotlin.runtime.time.Instant +import aws.smithy.kotlin.runtime.time.until +import kotlinx.coroutines.test.runTest +import kotlin.test.* +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.seconds + +class ClockSkewInterceptorTest { + val SKEWED_RESPONSE_CODE_DESCRIPTION = "RequestTimeTooSkewed" + val POSSIBLE_SKEWED_RESPONSE_CODE_DESCRIPTION = "InvalidSignatureException" + val NOT_SKEWED_RESPONSE_CODE_DESCRIPTION = "RequestThrottled" + + @Test + fun testNotSkewed() { + val clientTime = Instant.fromRfc5322("Wed, 6 Oct 2023 16:20:50 -0400") + val serverTime = Instant.fromRfc5322("Wed, 6 Oct 2023 16:20:50 -0400") + assertEquals(clientTime, serverTime) + assertFalse(clientTime.isSkewed(serverTime, NOT_SKEWED_RESPONSE_CODE_DESCRIPTION)) + } + + @Test + fun testSkewedByResponseCode() { + // clocks are exactly the same, but service returned skew error + val clientTime = Instant.fromRfc5322("Wed, 6 Oct 2023 16:20:50 -0400") + val serverTime = Instant.fromRfc5322("Wed, 6 Oct 2023 16:20:50 -0400") + assertTrue(clientTime.isSkewed(serverTime, SKEWED_RESPONSE_CODE_DESCRIPTION)) + assertEquals(0.days, clientTime.until(serverTime)) + } + + @Test + fun testSkewedByTime() { + val clientTime = Instant.fromRfc5322("Wed, 6 Oct 2023 16:20:50 -0400") + val serverTime = Instant.fromRfc5322("Wed, 7 Oct 2023 16:20:50 -0400") + assertTrue(clientTime.isSkewed(serverTime, POSSIBLE_SKEWED_RESPONSE_CODE_DESCRIPTION)) + assertEquals(1.days, clientTime.until(serverTime)) + } + + @Test + fun testNegativeSkewedByTime() { + val clientTime = Instant.fromRfc5322("Wed, 7 Oct 2023 16:20:50 -0400") + val serverTime = Instant.fromRfc5322("Wed, 6 Oct 2023 16:20:50 -0400") + assertTrue(clientTime.isSkewed(serverTime, POSSIBLE_SKEWED_RESPONSE_CODE_DESCRIPTION)) + assertEquals(-1.days, clientTime.until(serverTime)) + } + + @Test + fun testSkewThreshold() { + val minute = 20 + var clientTime = + Instant.fromRfc5322("Wed, 6 Oct 2023 16:${minute - CLOCK_SKEW_THRESHOLD.inWholeMinutes}:50 -0400") + val serverTime = Instant.fromRfc5322("Wed, 6 Oct 2023 16:$minute:50 -0400") + assertTrue(clientTime.isSkewed(serverTime, POSSIBLE_SKEWED_RESPONSE_CODE_DESCRIPTION)) + assertEquals(CLOCK_SKEW_THRESHOLD, clientTime.until(serverTime)) + + // shrink the skew by one second, crossing the threshold + clientTime += 1.seconds + assertFalse(clientTime.isSkewed(serverTime, POSSIBLE_SKEWED_RESPONSE_CODE_DESCRIPTION)) + } + + @Test + fun testClockSkewApplied() = runTest { + val serverTimeString = "Wed, 14 Sep 2023 16:20:50 -0400" + val serverTime = Instant.fromRfc5322(serverTimeString) + + val clientTimeString = "20231006T131604Z" + val clientTime = Instant.fromIso8601(clientTimeString) + + val client = getMockClient( + "bla".encodeToByteArray(), + Headers { append("Date", serverTimeString) }, + HttpStatusCode(403, "Forbidden"), + ) + + val req = HttpRequestBuilder().apply { + body = "bar".encodeToByteArray().toHttpBody() + } + req.headers.append("x-amz-date", clientTimeString) + + val op = newTestOperation(req, Unit) + + val clockSkewException = SdkBaseException() + clockSkewException.sdkErrorMetadata.attributes[ServiceErrorMetadata.ErrorCode] = + POSSIBLE_SKEWED_RESPONSE_CODE_DESCRIPTION + op.interceptors.add(FailedResultInterceptor(clockSkewException)) + + op.interceptors.add(ClockSkewInterceptor()) + + op.roundTrip(client, Unit) + + // Validate the skew got stored in execution context + val expectedSkew = clientTime.until(serverTime) + assertEquals(expectedSkew, op.context.getOrNull(HttpOperationContext.ClockSkew)) + } + + @Test + fun testClockSkewNotApplied() = runTest { + val serverTimeString = "Wed, 06 Oct 2023 13:16:04 -0000" + val clientTimeString = "20231006T131604Z" + assertEquals(Instant.fromRfc5322(serverTimeString), Instant.fromIso8601(clientTimeString)) + + val client = getMockClient( + "bla".encodeToByteArray(), + Headers { + append("Date", serverTimeString) + }, + HttpStatusCode(403, POSSIBLE_SKEWED_RESPONSE_CODE_DESCRIPTION), + ) + + val req = HttpRequestBuilder().apply { + body = "bar".encodeToByteArray().toHttpBody() + } + req.headers.append("x-amz-date", clientTimeString) + + val op = newTestOperation(req, Unit) + + val clockSkewException = SdkBaseException() + clockSkewException.sdkErrorMetadata.attributes[ServiceErrorMetadata.ErrorCode] = + POSSIBLE_SKEWED_RESPONSE_CODE_DESCRIPTION + op.interceptors.add(FailedResultInterceptor(clockSkewException)) + + op.interceptors.add(ClockSkewInterceptor()) + + // The request should fail because it's a non-retryable error, but there should be no skew detected. + assertFailsWith { + op.roundTrip(client, Unit) + } + + // Validate no skew was detected + assertNull(op.context.getOrNull(HttpOperationContext.ClockSkew)) + } + + /** + * An interceptor which returns a [Result.failure] with the given [exception] for the first [timesToFail] times its invoked. + * This simulates a service returning a clock skew exception and then successfully processing any successive requests. + */ + private class FailedResultInterceptor(val exception: SdkBaseException, val timesToFail: Int = 1) : HttpInterceptor { + var failuresSent = 0 + + override suspend fun modifyBeforeAttemptCompletion(context: ResponseInterceptorContext): Result { + if (failuresSent == timesToFail) { + return context.response + } + failuresSent += 1 + return Result.failure(exception) + } + } + + private fun getMockClient(response: ByteArray, responseHeaders: Headers = Headers.Empty, httpStatusCode: HttpStatusCode = HttpStatusCode.OK): SdkHttpClient { + val mockEngine = TestEngine { _, request -> + val body = object : HttpBody.SourceContent() { + override val contentLength: Long = response.size.toLong() + override fun readFrom(): SdkSource = response.source() + override val isOneShot: Boolean get() = false + } + val resp = HttpResponse(httpStatusCode, responseHeaders, body) + HttpCall(request, resp, Instant.now(), Instant.now()) + } + return SdkHttpClient(mockEngine) + } + + /** + * Create a new test operation using [serialized] as the already serialized version of the input type [I] + * and [deserialized] as the result of "deserialization" from an HTTP response. + */ + inline fun newTestOperation(serialized: HttpRequestBuilder, deserialized: O): SdkHttpOperation = + SdkHttpOperation.build { + serializer = HttpSerialize { _, _ -> serialized } + deserializer = HttpDeserialize { _, _ -> deserialized } + + // required operation context + operationName = "TestOperation" + serviceName = "TestService" + } +} diff --git a/runtime/protocol/http-client/common/src/aws/smithy/kotlin/runtime/http/operation/HttpOperationContext.kt b/runtime/protocol/http-client/common/src/aws/smithy/kotlin/runtime/http/operation/HttpOperationContext.kt index fe052d311..a3ccb60c4 100644 --- a/runtime/protocol/http-client/common/src/aws/smithy/kotlin/runtime/http/operation/HttpOperationContext.kt +++ b/runtime/protocol/http-client/common/src/aws/smithy/kotlin/runtime/http/operation/HttpOperationContext.kt @@ -8,7 +8,9 @@ package aws.smithy.kotlin.runtime.http.operation import aws.smithy.kotlin.runtime.InternalApi import aws.smithy.kotlin.runtime.http.HttpCall import aws.smithy.kotlin.runtime.operation.ExecutionContext +import aws.smithy.kotlin.runtime.time.Instant import aws.smithy.kotlin.runtime.util.* +import kotlin.time.Duration /** * Common configuration for an SDK (HTTP) operation/call @@ -50,6 +52,16 @@ public object HttpOperationContext { * Cached attribute level attributes (e.g. rpc.method, rpc.service, etc) */ public val OperationAttributes: AttributeKey = AttributeKey("aws.smithy.kotlin#OperationAttributes") + + /** + * The clock skew duration to apply to the signature calculation date during the operation + */ + public val ClockSkew: AttributeKey = AttributeKey("aws.smithy.kotlin#ClockSkew") + + /** + * The approximate signing time of the request, used to compute client clock skew. + */ + public val ClockSkewApproximateSigningTime: AttributeKey = AttributeKey("aws.smithy.kotlin#ClockSkewApproximateSigningTime") } internal val ExecutionContext.operationMetrics: OperationMetrics diff --git a/runtime/runtime-core/api/runtime-core.api b/runtime/runtime-core/api/runtime-core.api index 43c72a38c..7b2198edc 100644 --- a/runtime/runtime-core/api/runtime-core.api +++ b/runtime/runtime-core/api/runtime-core.api @@ -1250,6 +1250,7 @@ public final class aws/smithy/kotlin/runtime/time/InstantKt { public static final fun fromEpochMilliseconds (Laws/smithy/kotlin/runtime/time/Instant$Companion;J)Laws/smithy/kotlin/runtime/time/Instant; public static final fun getEpochMilliseconds (Laws/smithy/kotlin/runtime/time/Instant;)J public static final fun toEpochDouble (Laws/smithy/kotlin/runtime/time/Instant;)D + public static final fun until (Laws/smithy/kotlin/runtime/time/Instant;Laws/smithy/kotlin/runtime/time/Instant;)J } public class aws/smithy/kotlin/runtime/time/ParseException : aws/smithy/kotlin/runtime/SdkBaseException { diff --git a/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/time/Instant.kt b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/time/Instant.kt index a496391d8..f9ffd6395 100644 --- a/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/time/Instant.kt +++ b/runtime/runtime-core/common/src/aws/smithy/kotlin/runtime/time/Instant.kt @@ -5,6 +5,7 @@ package aws.smithy.kotlin.runtime.time import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds // FIXME - remove in favor of kotlinx-datetime before GA (assuming it's available). For now // we are stubbing this out for codegen purposes and supporting the various timestamp format parsers. @@ -100,3 +101,5 @@ public fun Instant.Companion.fromEpochMilliseconds(milliseconds: Long): Instant val ns = (milliseconds - secs * MILLISEC_PER_SEC) * NS_PER_MILLISEC return fromEpochSeconds(secs, ns.toInt()) } + +public fun Instant.until(other: Instant): Duration = (other.epochMilliseconds - epochMilliseconds).milliseconds diff --git a/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/time/InstantTest.kt b/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/time/InstantTest.kt index 94d33a041..cd75980dd 100644 --- a/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/time/InstantTest.kt +++ b/runtime/runtime-core/common/test/aws/smithy/kotlin/runtime/time/InstantTest.kt @@ -7,6 +7,10 @@ package aws.smithy.kotlin.runtime.time import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.nanoseconds import kotlin.time.Duration.Companion.seconds @@ -257,4 +261,26 @@ class InstantTest { assertEquals(test.second, actual, "test[$idx]: failed to format offset timestamp in UTC") } } + + @Test + fun testUntil() { + val untilTests = mapOf( + ("2013-01-01T00:00:00+00:00" to "2014-01-01T00:00:00+00:00") to 365.days, + ("2020-01-01T00:00:00+00:00" to "2021-01-01T00:00:00+00:00") to 366.days, // leap year! + ("2023-10-06T00:00:00+00:00" to "2023-10-06T00:00:00+00:00") to Duration.ZERO, + ("2023-10-06T00:00:00+00:00" to "2023-10-07T00:00:00+00:00") to 1.days, + ("2023-10-06T00:00:00+00:00" to "2023-10-06T01:00:00+00:00") to 1.hours, + ("2023-10-06T00:00:00+00:00" to "2023-10-06T00:01:00+00:00") to 1.minutes, + ("2023-10-06T00:00:00+00:00" to "2023-10-06T00:00:01+00:00") to 1.seconds, + ("2023-10-06T00:00:00+00:00" to "2023-10-06T12:12:12+00:00") to 12.hours + 12.minutes + 12.seconds, + ) + + for ((times, expectedDuration) in untilTests) { + val start = Instant.fromIso8601(times.first) + val end = Instant.fromIso8601(times.second) + + assertEquals(expectedDuration, start.until(end)) + assertEquals(end.until(start), -expectedDuration) + } + } }