Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/
package com.amplifyframework.aws.appsync.core.authorizers

import com.amplifyframework.aws.appsync.core.AppSyncAuthorizer
import com.amplifyframework.aws.appsync.core.AppSyncRequest
import com.amplifyframework.aws.appsync.core.util.AppSyncRequestSigner
import org.jetbrains.annotations.VisibleForTesting

/**
* Authorizer implementation that provides IAM signing through Amplify & AWS Kotlin SDK (Smithy).
*/
class AmplifyIamAuthorizer @VisibleForTesting internal constructor(
private val region: String,
private val requestSigner: AppSyncRequestSigner
) : AppSyncAuthorizer {

constructor(region: String) : this(region, requestSigner = AppSyncRequestSigner())

private val iamAuthorizer = IamAuthorizer { requestSigner.signAppSyncRequest(it, region) }

override suspend fun getAuthorizationHeaders(request: AppSyncRequest): Map<String, String> {
return iamAuthorizer.getAuthorizationHeaders(request)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/
package com.amplifyframework.aws.appsync.core.authorizers

import com.amplifyframework.auth.AuthCredentialsProvider
import com.amplifyframework.auth.CognitoCredentialsProvider
import com.amplifyframework.aws.appsync.core.AppSyncAuthorizer
import com.amplifyframework.aws.appsync.core.AppSyncRequest
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
import kotlin.coroutines.suspendCoroutine
import org.jetbrains.annotations.VisibleForTesting

/**
* Authorizer implementation that provides Cognito User Pool tokens via Amplify Auth.
*/
class AmplifyUserPoolAuthorizer @VisibleForTesting internal constructor(
private val accessTokenProvider: AccessTokenProvider
) : AppSyncAuthorizer {

constructor() : this(accessTokenProvider = AccessTokenProvider())

private val authTokenAuthorizer = AuthTokenAuthorizer(
fetchLatestAuthToken = accessTokenProvider::fetchLatestCognitoAuthToken
)

override suspend fun getAuthorizationHeaders(request: AppSyncRequest): Map<String, String> {
return authTokenAuthorizer.getAuthorizationHeaders(request)
}
}

internal class AccessTokenProvider(
private val credentialsProvider: AuthCredentialsProvider = CognitoCredentialsProvider()
) {
suspend fun fetchLatestCognitoAuthToken() = credentialsProvider.getAccessToken()

private suspend fun AuthCredentialsProvider.getAccessToken() = suspendCoroutine { continuation ->
getAccessToken(
{ token -> continuation.resume(token) },
{ error -> continuation.resumeWithException(error) }
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
/*
* Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/
package com.amplifyframework.aws.appsync.core.util

import aws.smithy.kotlin.runtime.InternalApi
import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials
import aws.smithy.kotlin.runtime.auth.awssigning.AwsSignedBodyHeader
import aws.smithy.kotlin.runtime.auth.awssigning.AwsSigner
import aws.smithy.kotlin.runtime.auth.awssigning.AwsSigningConfig
import aws.smithy.kotlin.runtime.auth.awssigning.DefaultAwsSigner
import aws.smithy.kotlin.runtime.http.Headers
import aws.smithy.kotlin.runtime.http.HttpBody
import aws.smithy.kotlin.runtime.http.request.HttpRequest
import aws.smithy.kotlin.runtime.http.toHttpBody
import aws.smithy.kotlin.runtime.net.url.Url
import com.amplifyframework.auth.AuthCredentialsProvider
import com.amplifyframework.auth.CognitoCredentialsProvider
import com.amplifyframework.aws.appsync.core.AppSyncRequest

internal class AppSyncRequestSigner(
private val credentialsProvider: AuthCredentialsProvider = CognitoCredentialsProvider(),
private val awsSigner: AwsSigner = DefaultAwsSigner
) {

suspend fun signAppSyncRequest(request: AppSyncRequest, region: String): Map<String, String> {
// First translate the Apollo request to a Smithy request so it can be signed
val smithyRequest = request.toSmithyRequest()

// Sign the Smithy request
val signedRequest = signRequest(
awsRegion = region,
credentials = credentialsProvider.resolve(),
service = "appsync",
request = smithyRequest
)

// Return the headers from the signed request
return signedRequest.headers.entries().associate { it.key to it.value.first() }
}

@OptIn(InternalApi::class)
private suspend fun signRequest(
awsRegion: String,
credentials: Credentials,
service: String,
request: HttpRequest
): HttpRequest {
val signingConfig = AwsSigningConfig {
region = awsRegion
useDoubleUriEncode = true
this.service = service
this.credentials = credentials
signedBodyHeader = AwsSignedBodyHeader.X_AMZ_CONTENT_SHA256
}

// Generate the signature for the smithy request
return awsSigner.sign(request, signingConfig).output
}

private fun AppSyncRequest.toSmithyRequest(): HttpRequest {
return HttpRequest(
method = method.toSmithyMethod(),
url = Url.parse(url),
headers = createSmithyHeaders(headers),
body = body?.toHttpBody() ?: HttpBody.Empty
)
}

private fun AppSyncRequest.HttpMethod.toSmithyMethod(): aws.smithy.kotlin.runtime.http.HttpMethod {
return when (this) {
AppSyncRequest.HttpMethod.GET -> aws.smithy.kotlin.runtime.http.HttpMethod.GET
AppSyncRequest.HttpMethod.POST -> aws.smithy.kotlin.runtime.http.HttpMethod.POST
}
}

private fun createSmithyHeaders(headers: Map<String, String>): Headers {
return Headers {
headers.forEach {
this.append(it.key, it.value)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/*
* Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/
package com.amplifyframework.aws.appsync.core.authorizers

import com.amplifyframework.aws.appsync.core.AppSyncRequest
import com.amplifyframework.aws.appsync.core.util.AppSyncRequestSigner
import io.kotest.matchers.maps.shouldContainExactly
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test

class AmplifyIamAuthorizerTest {
private val region = "us-east-1"

@Test
fun `iam authorizer gets token from amplify`() = runTest {
val request = mockk<AppSyncRequest>()
val signer = mockk<AppSyncRequestSigner> {
coEvery {
signAppSyncRequest(request, region)
} returns mapOf("Authorization" to "test-signature")
}

val authorizer = AmplifyIamAuthorizer(region, signer)

authorizer.getAuthorizationHeaders(request) shouldContainExactly mapOf("Authorization" to "test-signature")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/
package com.amplifyframework.aws.appsync.core.authorizers

import com.amplifyframework.auth.AuthCredentialsProvider
import com.amplifyframework.core.Consumer
import io.kotest.matchers.maps.shouldContainExactly
import io.mockk.CapturingSlot
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test

class AmplifyUserPoolAuthorizerTest {

@Test
fun `user pool authorizer gets token from amplify`() = runTest {
val expectedValue = "test-signature"
val slot = CapturingSlot<Consumer<String>>()
val cognitoCredentialsProvider = mockk<AuthCredentialsProvider> {
every { getAccessToken(capture(slot), any()) } answers {
slot.captured.accept(expectedValue)
}
}
val accessTokenProvider = AccessTokenProvider(cognitoCredentialsProvider)
val authorizer = AmplifyUserPoolAuthorizer(accessTokenProvider)

authorizer.getAuthorizationHeaders(mockk()) shouldContainExactly mapOf("Authorization" to expectedValue)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.amplifyframework.aws.appsync.core.util

import aws.smithy.kotlin.runtime.InternalApi
import aws.smithy.kotlin.runtime.auth.awssigning.AwsSigner
import aws.smithy.kotlin.runtime.http.Headers
import aws.smithy.kotlin.runtime.http.HttpMethod
import aws.smithy.kotlin.runtime.http.request.HttpRequest
import aws.smithy.kotlin.runtime.http.toHttpBody
import com.amplifyframework.auth.AuthCredentialsProvider
import com.amplifyframework.aws.appsync.core.AppSyncRequest
import io.kotest.matchers.equals.shouldBeEqual
import io.kotest.matchers.maps.shouldContain
import io.mockk.CapturingSlot
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test

class AppSyncRequestSignerTest {
@OptIn(InternalApi::class)
@Test
fun `signs request`() = runTest {
val expectedUrl = "https://amazon.com"
val expectedBody = "hello"
val expectedHeaders = mapOf("k1" to "v1")
val credentialProvider = mockk<AuthCredentialsProvider> {
coEvery { resolve(any()) } returns mockk()
}
val slot = CapturingSlot<HttpRequest>()
val signer = mockk<AwsSigner> {
coEvery { sign(capture(slot), any()) } returns mockk {
every { output.headers.entries() } returns mapOf("test" to listOf("value")).entries
}
}
val request = object : AppSyncRequest {
override val method = AppSyncRequest.HttpMethod.POST
override val url = expectedUrl
override val headers = expectedHeaders
override val body = expectedBody
}
val requestSigner = AppSyncRequestSigner(credentialProvider, signer)

val result = requestSigner.signAppSyncRequest(request, "us-east-1")
val signedRequest = slot.captured
signedRequest.url.toString() shouldBeEqual expectedUrl
signedRequest.method shouldBeEqual HttpMethod.POST
signedRequest.body shouldBeEqual expectedBody.toHttpBody()
signedRequest.headers shouldBeEqual Headers { append("k1", "v1") }
result shouldContain ("test" to "value")
}
}
1 change: 1 addition & 0 deletions appsync/aws-appsync-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ sourceSets.main {
}

dependencies {
implementation(libs.test.jetbrains.annotations)

testImplementation(libs.test.junit)
testImplementation(libs.test.mockk)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/*
* Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License").
* You may not use this file except in compliance with the License.
* A copy of the License is located at
*
* http://aws.amazon.com/apache2.0
*
* or in the "license" file accompanying this file. This file is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
* express or implied. See the License for the specific language governing
* permissions and limitations under the License.
*/
package com.amplifyframework.aws.appsync.core

/**
* Interface for classes that provide different types of authorization for AppSync. AppSync supports various auth
* modes, including API Key, Cognito User Pools, OIDC, Lambda-based authorization, and IAM policies. Implementations
* of this interface can be used to provide the specific headers and payloads needed for the auth mode being used.
*/
interface AppSyncAuthorizer {
suspend fun getAuthorizationHeaders(request: AppSyncRequest): Map<String, String>
}
Loading