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.

type Query {
    getMagicNumber: Int
}

 

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:

#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)

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.

query {
    getMagicNumber
}

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.