Skip to content

Commit 7be8e23

Browse files
authored
feat(data): AppSync Authorizers (#3011)
1 parent 3cf2d96 commit 7be8e23

File tree

18 files changed

+712
-0
lines changed

18 files changed

+712
-0
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*
2+
* Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
package com.amplifyframework.aws.appsync.core.authorizers
16+
17+
import com.amplifyframework.aws.appsync.core.AppSyncAuthorizer
18+
import com.amplifyframework.aws.appsync.core.AppSyncRequest
19+
import com.amplifyframework.aws.appsync.core.util.AppSyncRequestSigner
20+
import org.jetbrains.annotations.VisibleForTesting
21+
22+
/**
23+
* Authorizer implementation that provides IAM signing through Amplify & AWS Kotlin SDK (Smithy).
24+
*/
25+
class AmplifyIamAuthorizer @VisibleForTesting internal constructor(
26+
private val region: String,
27+
private val requestSigner: AppSyncRequestSigner
28+
) : AppSyncAuthorizer {
29+
30+
constructor(region: String) : this(region, requestSigner = AppSyncRequestSigner())
31+
32+
private val iamAuthorizer = IamAuthorizer { requestSigner.signAppSyncRequest(it, region) }
33+
34+
override suspend fun getAuthorizationHeaders(request: AppSyncRequest): Map<String, String> {
35+
return iamAuthorizer.getAuthorizationHeaders(request)
36+
}
37+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/*
2+
* Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
package com.amplifyframework.aws.appsync.core.authorizers
16+
17+
import com.amplifyframework.auth.AuthCredentialsProvider
18+
import com.amplifyframework.auth.CognitoCredentialsProvider
19+
import com.amplifyframework.aws.appsync.core.AppSyncAuthorizer
20+
import com.amplifyframework.aws.appsync.core.AppSyncRequest
21+
import kotlin.coroutines.resume
22+
import kotlin.coroutines.resumeWithException
23+
import kotlin.coroutines.suspendCoroutine
24+
import org.jetbrains.annotations.VisibleForTesting
25+
26+
/**
27+
* Authorizer implementation that provides Cognito User Pool tokens via Amplify Auth.
28+
*/
29+
class AmplifyUserPoolAuthorizer @VisibleForTesting internal constructor(
30+
private val accessTokenProvider: AccessTokenProvider
31+
) : AppSyncAuthorizer {
32+
33+
constructor() : this(accessTokenProvider = AccessTokenProvider())
34+
35+
private val authTokenAuthorizer = AuthTokenAuthorizer(
36+
fetchLatestAuthToken = accessTokenProvider::fetchLatestCognitoAuthToken
37+
)
38+
39+
override suspend fun getAuthorizationHeaders(request: AppSyncRequest): Map<String, String> {
40+
return authTokenAuthorizer.getAuthorizationHeaders(request)
41+
}
42+
}
43+
44+
internal class AccessTokenProvider(
45+
private val credentialsProvider: AuthCredentialsProvider = CognitoCredentialsProvider()
46+
) {
47+
suspend fun fetchLatestCognitoAuthToken() = credentialsProvider.getAccessToken()
48+
49+
private suspend fun AuthCredentialsProvider.getAccessToken() = suspendCoroutine { continuation ->
50+
getAccessToken(
51+
{ token -> continuation.resume(token) },
52+
{ error -> continuation.resumeWithException(error) }
53+
)
54+
}
55+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/*
2+
* Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
package com.amplifyframework.aws.appsync.core.util
16+
17+
import aws.smithy.kotlin.runtime.InternalApi
18+
import aws.smithy.kotlin.runtime.auth.awscredentials.Credentials
19+
import aws.smithy.kotlin.runtime.auth.awssigning.AwsSignedBodyHeader
20+
import aws.smithy.kotlin.runtime.auth.awssigning.AwsSigner
21+
import aws.smithy.kotlin.runtime.auth.awssigning.AwsSigningConfig
22+
import aws.smithy.kotlin.runtime.auth.awssigning.DefaultAwsSigner
23+
import aws.smithy.kotlin.runtime.http.Headers
24+
import aws.smithy.kotlin.runtime.http.HttpBody
25+
import aws.smithy.kotlin.runtime.http.request.HttpRequest
26+
import aws.smithy.kotlin.runtime.http.toHttpBody
27+
import aws.smithy.kotlin.runtime.net.url.Url
28+
import com.amplifyframework.auth.AuthCredentialsProvider
29+
import com.amplifyframework.auth.CognitoCredentialsProvider
30+
import com.amplifyframework.aws.appsync.core.AppSyncRequest
31+
32+
internal class AppSyncRequestSigner(
33+
private val credentialsProvider: AuthCredentialsProvider = CognitoCredentialsProvider(),
34+
private val awsSigner: AwsSigner = DefaultAwsSigner
35+
) {
36+
37+
suspend fun signAppSyncRequest(request: AppSyncRequest, region: String): Map<String, String> {
38+
// First translate the Apollo request to a Smithy request so it can be signed
39+
val smithyRequest = request.toSmithyRequest()
40+
41+
// Sign the Smithy request
42+
val signedRequest = signRequest(
43+
awsRegion = region,
44+
credentials = credentialsProvider.resolve(),
45+
service = "appsync",
46+
request = smithyRequest
47+
)
48+
49+
// Return the headers from the signed request
50+
return signedRequest.headers.entries().associate { it.key to it.value.first() }
51+
}
52+
53+
@OptIn(InternalApi::class)
54+
private suspend fun signRequest(
55+
awsRegion: String,
56+
credentials: Credentials,
57+
service: String,
58+
request: HttpRequest
59+
): HttpRequest {
60+
val signingConfig = AwsSigningConfig {
61+
region = awsRegion
62+
useDoubleUriEncode = true
63+
this.service = service
64+
this.credentials = credentials
65+
signedBodyHeader = AwsSignedBodyHeader.X_AMZ_CONTENT_SHA256
66+
}
67+
68+
// Generate the signature for the smithy request
69+
return awsSigner.sign(request, signingConfig).output
70+
}
71+
72+
private fun AppSyncRequest.toSmithyRequest(): HttpRequest {
73+
return HttpRequest(
74+
method = method.toSmithyMethod(),
75+
url = Url.parse(url),
76+
headers = createSmithyHeaders(headers),
77+
body = body?.toHttpBody() ?: HttpBody.Empty
78+
)
79+
}
80+
81+
private fun AppSyncRequest.HttpMethod.toSmithyMethod(): aws.smithy.kotlin.runtime.http.HttpMethod {
82+
return when (this) {
83+
AppSyncRequest.HttpMethod.GET -> aws.smithy.kotlin.runtime.http.HttpMethod.GET
84+
AppSyncRequest.HttpMethod.POST -> aws.smithy.kotlin.runtime.http.HttpMethod.POST
85+
}
86+
}
87+
88+
private fun createSmithyHeaders(headers: Map<String, String>): Headers {
89+
return Headers {
90+
headers.forEach {
91+
this.append(it.key, it.value)
92+
}
93+
}
94+
}
95+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/*
2+
* Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
package com.amplifyframework.aws.appsync.core.authorizers
16+
17+
import com.amplifyframework.aws.appsync.core.AppSyncRequest
18+
import com.amplifyframework.aws.appsync.core.util.AppSyncRequestSigner
19+
import io.kotest.matchers.maps.shouldContainExactly
20+
import io.mockk.coEvery
21+
import io.mockk.mockk
22+
import kotlinx.coroutines.test.runTest
23+
import org.junit.Test
24+
25+
class AmplifyIamAuthorizerTest {
26+
private val region = "us-east-1"
27+
28+
@Test
29+
fun `iam authorizer gets token from amplify`() = runTest {
30+
val request = mockk<AppSyncRequest>()
31+
val signer = mockk<AppSyncRequestSigner> {
32+
coEvery {
33+
signAppSyncRequest(request, region)
34+
} returns mapOf("Authorization" to "test-signature")
35+
}
36+
37+
val authorizer = AmplifyIamAuthorizer(region, signer)
38+
39+
authorizer.getAuthorizationHeaders(request) shouldContainExactly mapOf("Authorization" to "test-signature")
40+
}
41+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
* Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
package com.amplifyframework.aws.appsync.core.authorizers
16+
17+
import com.amplifyframework.auth.AuthCredentialsProvider
18+
import com.amplifyframework.core.Consumer
19+
import io.kotest.matchers.maps.shouldContainExactly
20+
import io.mockk.CapturingSlot
21+
import io.mockk.every
22+
import io.mockk.mockk
23+
import kotlinx.coroutines.test.runTest
24+
import org.junit.Test
25+
26+
class AmplifyUserPoolAuthorizerTest {
27+
28+
@Test
29+
fun `user pool authorizer gets token from amplify`() = runTest {
30+
val expectedValue = "test-signature"
31+
val slot = CapturingSlot<Consumer<String>>()
32+
val cognitoCredentialsProvider = mockk<AuthCredentialsProvider> {
33+
every { getAccessToken(capture(slot), any()) } answers {
34+
slot.captured.accept(expectedValue)
35+
}
36+
}
37+
val accessTokenProvider = AccessTokenProvider(cognitoCredentialsProvider)
38+
val authorizer = AmplifyUserPoolAuthorizer(accessTokenProvider)
39+
40+
authorizer.getAuthorizationHeaders(mockk()) shouldContainExactly mapOf("Authorization" to expectedValue)
41+
}
42+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package com.amplifyframework.aws.appsync.core.util
2+
3+
import aws.smithy.kotlin.runtime.InternalApi
4+
import aws.smithy.kotlin.runtime.auth.awssigning.AwsSigner
5+
import aws.smithy.kotlin.runtime.http.Headers
6+
import aws.smithy.kotlin.runtime.http.HttpMethod
7+
import aws.smithy.kotlin.runtime.http.request.HttpRequest
8+
import aws.smithy.kotlin.runtime.http.toHttpBody
9+
import com.amplifyframework.auth.AuthCredentialsProvider
10+
import com.amplifyframework.aws.appsync.core.AppSyncRequest
11+
import io.kotest.matchers.equals.shouldBeEqual
12+
import io.kotest.matchers.maps.shouldContain
13+
import io.mockk.CapturingSlot
14+
import io.mockk.coEvery
15+
import io.mockk.every
16+
import io.mockk.mockk
17+
import kotlinx.coroutines.test.runTest
18+
import org.junit.Test
19+
20+
class AppSyncRequestSignerTest {
21+
@OptIn(InternalApi::class)
22+
@Test
23+
fun `signs request`() = runTest {
24+
val expectedUrl = "https://amazon.com"
25+
val expectedBody = "hello"
26+
val expectedHeaders = mapOf("k1" to "v1")
27+
val credentialProvider = mockk<AuthCredentialsProvider> {
28+
coEvery { resolve(any()) } returns mockk()
29+
}
30+
val slot = CapturingSlot<HttpRequest>()
31+
val signer = mockk<AwsSigner> {
32+
coEvery { sign(capture(slot), any()) } returns mockk {
33+
every { output.headers.entries() } returns mapOf("test" to listOf("value")).entries
34+
}
35+
}
36+
val request = object : AppSyncRequest {
37+
override val method = AppSyncRequest.HttpMethod.POST
38+
override val url = expectedUrl
39+
override val headers = expectedHeaders
40+
override val body = expectedBody
41+
}
42+
val requestSigner = AppSyncRequestSigner(credentialProvider, signer)
43+
44+
val result = requestSigner.signAppSyncRequest(request, "us-east-1")
45+
val signedRequest = slot.captured
46+
signedRequest.url.toString() shouldBeEqual expectedUrl
47+
signedRequest.method shouldBeEqual HttpMethod.POST
48+
signedRequest.body shouldBeEqual expectedBody.toHttpBody()
49+
signedRequest.headers shouldBeEqual Headers { append("k1", "v1") }
50+
result shouldContain ("test" to "value")
51+
}
52+
}

appsync/aws-appsync-core/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ sourceSets.main {
6565
}
6666

6767
dependencies {
68+
implementation(libs.test.jetbrains.annotations)
6869

6970
testImplementation(libs.test.junit)
7071
testImplementation(libs.test.mockk)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
/*
2+
* Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License").
5+
* You may not use this file except in compliance with the License.
6+
* A copy of the License is located at
7+
*
8+
* http://aws.amazon.com/apache2.0
9+
*
10+
* or in the "license" file accompanying this file. This file is distributed
11+
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
12+
* express or implied. See the License for the specific language governing
13+
* permissions and limitations under the License.
14+
*/
15+
package com.amplifyframework.aws.appsync.core
16+
17+
/**
18+
* Interface for classes that provide different types of authorization for AppSync. AppSync supports various auth
19+
* modes, including API Key, Cognito User Pools, OIDC, Lambda-based authorization, and IAM policies. Implementations
20+
* of this interface can be used to provide the specific headers and payloads needed for the auth mode being used.
21+
*/
22+
interface AppSyncAuthorizer {
23+
suspend fun getAuthorizationHeaders(request: AppSyncRequest): Map<String, String>
24+
}

0 commit comments

Comments
 (0)