Front-End Web & Mobile
Implement AWS AppSync custom authorization with pipeline resolvers
September 14, 2021: Amazon Elasticsearch Service has been renamed to Amazon OpenSearch Service. See details.
AWS AppSync is a fully managed serverless GraphQL service for application data with integrated real-time data queries, synchronization, communications, and offline programming features. The AppSync endpoints provide built-in fine-grained API security based on four different modes, always requiring authorization before allowing access to clients:
- API Keys (API_KEY)
- Amazon Cognito User Pools (AMAZON_COGNITO_USER_POOLS)
- OpenID Connect (OPENID_CONNECT)
- AWS Identity and Access Management (AWS_IAM)
For more information on AppSync’s built-in security and authorization features, see our GraphQL security primer blog post.
While the authorization modes above cover most of the use cases, what if you have a requirement to implement your own custom logic to allow users to access your GraphQL API? For example, you may want to authorize a caller access if their IP address is in an allowed list. In this article, we go over an approach that leverages AppSync pipeline resolvers and AWS Lambda functions to achieve our customized API authorization goal.
Here is how it works:
Each GraphQL API is defined by a single GraphQL schema. The schema contains fields that define the object types and operations that can be performed in your API. GraphQL resolvers connect the fields in the schema to data in data sources. There are two types of resolvers in AppSync: unit resolvers and pipeline resolvers. A pipeline resolver enables orchestrating multiple operations (called Functions, not to be confused with Lambda functions) and execute them in sequence, to resolve a GraphQL field in a single API call.
We cover the IP validation scenario to authorize API calls. In our example, a pipeline resolver is attached to a query field. When AppSync receives the caller’s request, it executes two Lambda functions in sequence, trying to resolve the field. The first Lambda function checks the caller’s IP, then returns true/false depending in the IP is in the allowed list. The second Lambda function is only called if the first Lambda function returns an {authorized: true}
response, otherwise an “Unauthorized” error is returned. The API will use an API key to allow initial access to clients, preceding the Lambda authorization itself.
You can quickly deploy the sample AppSync backend in your own account with the following template using the AWS CloudFormation console:
AWSTemplateFormatVersion: '2010-09-09'
Description: >
Creates an AWS AppSync API with custom authorization via pipeline resolvers
Resources:
LambdaCustomAuthorizerAPI:
Type: AWS::AppSync::GraphQLApi
Properties:
AuthenticationType: API_KEY
Name: LambdaCustomAuthorizerAPI
AppSyncSchema:
Type: AWS::AppSync::GraphQLSchema
Properties:
ApiId: !GetAtt LambdaCustomAuthorizerAPI.ApiId
Definition: >
type Query {
getMagicNumber: Int
}
AppSyncAPIKey:
Type: AWS::AppSync::ApiKey
Properties:
ApiId: !GetAtt LambdaCustomAuthorizerAPI.ApiId
Description: API Key used to make AppSync API calls
AuthorizerLambda:
Type: AWS::Lambda::Function
Properties:
Code:
ZipFile: |
// TODO: Replace with your own allowed IPs
const allowedIps = [
"123.456.555.555",
"6.6.6.6"
]
exports.handler = async (event) => {
console.log(event);
var callerIp = event.request.headers["x-forwarded-for"].split(',')[0]
console.log("Caller IP is: " + callerIp);
if (callerIp && allowedIps.includes(callerIp)) {
return {
authorized: true
}
} else {
return {
authorized: false
}
}
};
Description: Lambda function that checks the caller IP against an allowed list
FunctionName: appsync-lambda-authorizer
Handler: index.handler
MemorySize: 128
Role: !GetAtt LambdaBasicRole.Arn
Runtime: nodejs12.x
Timeout: 5
MagicNumberLambda:
Type: AWS::Lambda::Function
Properties:
Code:
ZipFile: |
exports.handler = async (event) => {
// Return a magical random number between 0 to 100.
return Math.round(Math.random() * 100)
};
Description: Lambda function that returns a magic number between 0 to 100.
FunctionName: magic-number-lambda
Handler: index.handler
MemorySize: 128
Role: !GetAtt LambdaBasicRole.Arn
Runtime: nodejs12.x
Timeout: 5
LambdaBasicRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
Service:
- lambda.amazonaws.com
Action:
- sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
AppSyncLambdaServiceRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
Service:
- appsync.amazonaws.com
Action:
- sts:AssumeRole
Path: /
Policies:
- PolicyName: root
PolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Action: 'lambda:invokeFunction'
Resource:
- !GetAtt AuthorizerLambda.Arn
- !GetAtt MagicNumberLambda.Arn
AuthorizerDataSource:
Type: AWS::AppSync::DataSource
Properties:
ApiId: !GetAtt LambdaCustomAuthorizerAPI.ApiId
Description: Lambda data source that performs custom authorization logic
LambdaConfig:
LambdaFunctionArn: !GetAtt AuthorizerLambda.Arn
Name: AuthorizerDataSource
ServiceRoleArn: !GetAtt AppSyncLambdaServiceRole.Arn
Type: AWS_LAMBDA
MagicNumberDataSource:
Type: AWS::AppSync::DataSource
Properties:
ApiId: !GetAtt LambdaCustomAuthorizerAPI.ApiId
Description: Lambda data source that returns a magic number
LambdaConfig:
LambdaFunctionArn: !GetAtt MagicNumberLambda.Arn
Name: MagicNumberDataSource
ServiceRoleArn: !GetAtt AppSyncLambdaServiceRole.Arn
Type: AWS_LAMBDA
AuthorizerFunction:
Type: AWS::AppSync::FunctionConfiguration
Properties:
ApiId: !GetAtt LambdaCustomAuthorizerAPI.ApiId
DataSourceName: !GetAtt AuthorizerDataSource.Name
Description: Authorizer function
FunctionVersion: 2018-05-29
Name: AuthorizerFunction
RequestMappingTemplate: |
{
"operation": "Invoke",
"payload": $util.toJson($context)
}
ResponseMappingTemplate: |
#if($ctx.error)
$util.error($ctx.error.message, $ctx.error.type)
#end
#set($authorized = $ctx.result.authorized)
#if(!$authorized) {
$utils.unauthorized()
}
#end
$util.toJson($context.result)
GetMagicNumberFunction:
Type: AWS::AppSync::FunctionConfiguration
Properties:
ApiId: !GetAtt LambdaCustomAuthorizerAPI.ApiId
DataSourceName: !GetAtt MagicNumberDataSource.Name
Description: Magic number function
FunctionVersion: 2018-05-29
Name: GetMagicNumberFunction
RequestMappingTemplate: |
{
"operation": "Invoke",
"payload": $util.toJson($context.args)
}
ResponseMappingTemplate: |
#if($ctx.error)
$util.error($ctx.error.message, $ctx.error.type)
#end
$util.toJson($context.result)
GetMagicNumberResolver:
Type: AWS::AppSync::Resolver
DependsOn: AppSyncSchema
Properties:
ApiId: !GetAtt LambdaCustomAuthorizerAPI.ApiId
FieldName: getMagicNumber
Kind: PIPELINE
PipelineConfig:
Functions:
- !GetAtt AuthorizerFunction.FunctionId
- !GetAtt GetMagicNumberFunction.FunctionId
TypeName: Query
RequestMappingTemplate: "{}"
ResponseMappingTemplate: |
$util.toJson($ctx.result)
Now let’s break it down and explain step by step.
Create the AppSync API
First let’s create an AppSync API. If you already have an existing API you can skip the API creation step, but ensure in Settings, the Default authorization mode is set to API key, and a valid, non-expired API key has been created and is assigned to the API.
Go to AWS AppSync in the console. Click Create API. Select Build from scratch, then click Start. Give your API a name, for example, “Magic Number Generator”. After the API is created, choose Schema under the API name, enter the following GraphQL schema. Click Save Schema.
Create the Data Sources
Data sources are resources in your AWS account that GraphQL APIs can interact with to create, retrieve or update data. AWS AppSync supports AWS Lambda, Amazon DynamoDB, relational databases (Amazon Aurora Serverless), Amazon OpenSearch Service (successor to Amazon Elasticsearch Service), and HTTP endpoints as data sources. We use two Lambda functions as our data sources.
First, create a Node.JS Lambda function that acts as your custom authorizer. Let’s call it appsync-lambda-authorizer
. You can use the sample code below that checks against a simple list and returns true or false, depending if the caller’s IP exists in the list or not. The list is hard coded for demonstration purposes, in production you can use an Amazon DynamoDB table to store all the permitted IPs. The Lambda examines the caller’s IP based on the HTTPS request header:
// TODO: Replace with your own allowed IPs
const allowedIps = [
"555.555.555.555",
"6.6.6.6"
]
exports.handler = async (event) => {
console.log(event);
var callerIp = event.request.headers["x-forwarded-for"].split(',')[0]
console.log("Caller IP is: " + callerIp);
if (callerIp && allowedIps.includes(callerIp)) {
return {
authorized: true
}
} else {
return {
authorized: false
}
}
};
Next, create a Lambda function to perform your actual business logic. For simplicity, this Lambda function will just return a random number between 0 to 100. Let’s call it magic-number-lambda
. You can use the following sample code, or implement your own, as long as the Lambda function returns an integer as the result.
exports.handler = async (event) => {
// Return a magical random number between 0 to 100.
return Math.round(Math.random() * 100)
};
Go to the AppSync console. Select Data Sources under your API name. Click Create data source. Here you want to create two separate data sources, each one pointing to the Lambda functions you just created. We name the first data source AuthorizerDataSource
, which points to the appsync-lambda-authorizer
Lambda function.
Click Create. Repeat the same process for the magic number Lambda function. We call the second data source MagicNumberDataSource
.
Create the Pipeline Resolver Functions
Pipeline resolvers use VTL functions to define the pipeline logic. Now select Functions on the left side under the API name. Click Create function. Choose the newly created data source AuthorizerDataSource
. Let’s call this Function Authorizer
. We must modify the default request mapping template since we need to access the whole context object to get the caller’s IP address:
{
"operation": "Invoke",
"payload": $util.toJson($context)
}
The key to leverage the authorization performed by the appsync-lambda-authorizer
Lambda function is in the response mapping template of the Authorizer
function. We use an if/else conditional logic to allow execution of the next magic number Lambda function, otherwise return an “Unauthorized” error:
Note the $authorized
variable accesses the execution result from the Lambda function and $ctx.result
returns the result in JSON format, so you must ensure the key it uses (“authorized”) matches what the Lambda function returns.
Now let’s create the second Function, which executes the business logic and returns a magic number. Select MagicNumberDataSource
as the data source, give it a Function name GetMagicNumber
.
Leave the default request and response mapping templates, and click Create function.
Wire the Schema
Now we have done all the prep work, it’s time to wire the pipeline functions with the schema. Select Schema on the left menu under the API name.
In the Query section on the right side, next to getMagicNumber
, select Attach to create a resolver. Click Convert to pipeline resolver. We don’t need to customize before and after mapping templates for this example to add logic before or after the pipeline is executed, so you can keep the default templates. Click Add function. Select the Authorizer
function first, then GetMagicNumber
function next to ensure the execution order.
Click Create resolver. Now we are done!
Taking it for a spin
Let’s run a test. We can use Postman to send requests to the AppSync endpoint. First ensure your own IP address is part of the allowed IPs list inside of appsync-lambda-authorizer
Lambda function, you can use your IP lookup service of choice to obtain the address. Retrieve the AppSync API endpoint URL and the API Key from the Settings screen in the AppSync console. In Postman under Headers, copy the API Key and send it as a header x-api-key
. Under Body, choose GraphQL, and enter a simple query to get a magic number.
Click Send. You should be able to get your magic number back!
If you remove your own IP from the appsync-lambda-authorizer
Lambda function, running the same test results in an Unauthorized
error.
Conclusion
In this article we walked through how to setup a pipeline resolver in AppSync with Lambda functions to perform custom authorization for GraphQL API calls. To learn more about AppSync pipeline resolves, please check our documentation.
What else would you like to see in AWS AppSync authorization and security? Let us know if you have any ideas, if so feel free to create a feature request in our GitHub repository. Our team constantly monitors the repository and we’re always interested on developer feedback. Go build securely with custom authorization in AppSync!
Jane Shen is an AWS Professional Services Cloud Architect based in Toronto, Canada.