How to call API Gateway from AppSync with IAM authorization

Integrating AppSync with a private REST API

Author's image
Tamás Sallai
4 mins

Private API endpoints

With AppSync's HTTP data source it's possible to call HTTP endpoints directly from a resolver without adding a Lambda function in the middle. This makes it possible to configure a bridge between GraphQL and REST: clients send GraphQL queries to AppSync that it translates to REST-like HTTP requests.

This is a fairly common scenario. A component offers a REST API and AppSync interacts with that in the background. And thanks to AWS signatures this can happen in a secure way: AppSync can call the API as it can use an IAM Role with the necessary permissions, but for the public it's not available.

In this article, we'll see how to configure AppSync to send a signed request to an API Gateway HTTP API using the HTTP data source. We'll look into the permission model and what data is going between the two services.

API Gateway setup

The API defines an integration for a Lambda function (a fairly usual setup) and a route. Then the route's authorization_type is set to AWS_IAM.

Auth type for the route is IAM

When this API is called without a valid signature it returns a 403 error:

{"message":"Forbidden"}

This makes it a private API and access is controlled by IAM. In effect, the caller needs an IAM identity with the necessary permissions to access it.

AppSync

IAM Role

First, set up a role with the necessary permission:

data "aws_iam_policy_document" "appsync" {
	statement {
		actions = [
			"execute-api:Invoke"
		]
		resources = [
			"${aws_apigatewayv2_api.api.execution_arn}/*"
		]
	}
}

Note that the resource is the execution ARN and not the normal API ARN. The execution ARN is in the format of arn:aws:execute-api:<region>:<account>:<apiid> and that is what gives access to invoke the API.

Data source

Next, add an HTTP data source with the endpoint and the signature configuration:

data "aws_arn" "apigw" {
  arn = aws_apigatewayv2_api.api.arn
}

resource "aws_appsync_datasource" "apigw" {
  api_id           = aws_appsync_graphql_api.appsync.id
  name             = "apigw"
  service_role_arn = aws_iam_role.appsync.arn
  type             = "HTTP"
	http_config {
		endpoint = aws_apigatewayv2_api.api.api_endpoint
		authorization_config {
			authorization_type = "AWS_IAM"
			aws_iam_config {
				signing_region = data.aws_arn.apigw.region
				signing_service_name = "execute-api"
			}
		}
	}
}

Here, the type is HTTP and the service role is the role that has the permission to call the API.

Then the authorization_config defines the parameters for the signature. The signing_region is the region of the target API, so an aws_arn element extracts that from the API ARN. Then the signing_service_name is execute-api as the signature is for the target service.

Resolver

The resolver configured with this data source has everything to call the API and just needs to fill in the details of the request. It can specify the HTTP method, the request path, headers, the body, and the query parameters.

In this example, it sends a GET request with the path from the resolver argument:

{
	"version": "2018-05-29",
	"method": "GET",
	"params": {
		"headers": {
			"Content-Type" : "application/json"
		}
	},
	"resourcePath": $util.toJson($ctx.args.path)
}

Then the response contains the headers and the body coming from the API.

#if ($ctx.error)
	$util.error($ctx.error.message, $ctx.error.type)
#end
#if ($ctx.result.statusCode < 200 || $ctx.result.statusCode >= 300)
	$util.error($ctx.result.body, "StatusCode$ctx.result.statusCode")
#end
$ctx.result.body

Note the error handling here: the first part checks if there was any problem with the request, while the second one checks the status code.

After that, the resolver simply returns the response body.

Testing

Let's see how it works in practice!

In the test setup, the API Gateway API forwards the requests to a Lambda function that simply returns the event object it got as a JSON:

module.exports.handler = async (event, context) => {
	return {
		event,
	}
};

This provides visibility into what happens between AppSync and API Gateway as otherwise the request and the response happen in the background.

Call the AppSync API:

query MyQuery {
  call(path: "/abc")
}

Not surprisingly, the response is a stringified JSON with a lot of fields:

{
	"event": {
		"version": "2.0",
		"routeKey": "$default",
		"rawPath": "/abc",
		"rawQueryString": "",
		"headers": {
			"authorization": "AWS4-HMAC-SHA256 Credential=AS...",
			"content-length": "0",
			"content-type": "application/json",
			"date": "Fri, 22 Jul 2022 09:26:08 Z",
			"host": "spk61twlle.execute-api.eu-central-1.amazonaws.com",
			"user-agent": "AWSAppSync-Http-Client",
			"x-amz-content-sha256": "e3b0c...",
			"x-amz-date": "20220722T092608Z",
			"x-amz-security-token": "IQo...",
			"x-amzn-trace-id": "Root=1-62d...",
			"x-forwarded-for": "18.184....",
			"x-forwarded-port": "443",
			"x-forwarded-proto": "https"
		},
		"requestContext": {
			"accountId": "278868411450",
			"apiId": "spk61twlle",
			"authorizer": {
				"iam": {
					"accessKey": "ASIAUB3O2IQ5IET4V3K3",
					"accountId": "278868411450",
					"callerId": "AROAUB3O2IQ5G3X54F5GI:APPSYNC_ASSUME_ROLE",
					"cognitoIdentity": null,
					"principalOrgId": "aws:PrincipalOrgID",
					"userArn": "arn:aws:sts::278868411450:assumed-role/...",
					"userId": "AROAUB3O2IQ5G3X54F5GI:APPSYNC_ASSUME_ROLE"
				}
			},
			"domainName": "spk61twlle.execute-api.eu-central-1.amazonaws.com",
			"domainPrefix": "spk61twlle",
			"http": {
				"method": "GET",
				"path": "/abc",
				"protocol": "HTTP/1.1",
				"sourceIp": "18.184.155.219",
				"userAgent": "AWSAppSync-Http-Client"
			},
			"requestId": "VqX_piA_FiAEPEw=",
			"routeKey": "$default",
			"stage": "$default",
			"time": "22/Jul/2022:09:26:08 +0000",
			"timeEpoch": 1658481968764
		},
		"isBase64Encoded": false
	}
}

The interesting parts are the headers related to the signing. API Gateway checks those, verify the signature, and delegates permission verification to IAM. Then it also fills the requestContext.authorizer with the IAM identity. Then in the http part, the method and the path from the resolver is there.

Conclusion

AppSync natively supports sending signed HTTP requests to any endpoint. This makes it possible to integrate with a private API Gateway API in a secure way. Permissions are managed by IAM and all credentials are temporary access tokens managed by AWS.

August 16, 2022

Free PDF guide

Sign up to our newsletter and download the "How Cognito User Pools work" guide.


In this article