Front-End Web & Mobile

Secure AWS AppSync with API Keys using the AWS CDK

AWS AppSync is a managed GraphQL service offered by AWS. As a managed service, there are no servers to keep track of and scaling up and down due to traffic is automatically handled by AWS.

In AppSync, there is no concept of a fully-public API. Every request must be protected by one of the following:

  • API Key: A public-facing, randomly generated key. The expiration date can be as high as 365 days into the future, and the API Key can be extended once more before having to be regenerated.
  • IAM Permissions: Using a configured identity provider, such as an Amazon Cognito identity pool, users can have temporary credentials configured for both authenticated and unauthenticated access.
  • Amazon Cognito: AppSync provides direct integration with Cognito user pools. This allows fields to be protected using claims sent in the identity token.
  • Lambda Authorization: Useful in cases where a custom authorization flow is necessary, this allows a Lambda function to dictate whether a user is allowed or denied access.

In this post, we’ll discuss how access can be enabled for guest users–that is, users that need access to our data, but do not have a mechanism for logging in. To demonstrate this, we’ll create an AppSync API that is protected with an API key. Once deployed, this backend can be used in a NextJS application through the use of the AWS Amplify JavaScript libraries.

Project overview

Architecture diagram of a protected AppSync API with a DynamoDB datasource and Lambda function

The full backend repo contains code needed to build a simple profile viewer. Imagine there is a public event happening and users are allowed to view basic information about one another.

The frontend application will pull the data from our database via an AppSync query, and display it on the webpage. To mimic users being added to our database by an admin, a Lambda function is configured to run on an interval. The Lambda function calls the randomUser API and add a user to the database.

Project setup

To begin, you’ll want to initialize our CDK application by running the following:

mkdir guest-user-backend && cd $_ && npx aws-cdk init -l typescript

This will create a directory called guest-user-backend, change into it, and scaffold a CDK project.

Creating our User database

Creating a database in the CDK is a well-supported task. The following code snippet contains the imports needed.

import {
    CfnOutput,
    Duration,
    Expiration,
    RemovalPolicy,
    Stack,
    StackProps,
} from 'aws-cdk-lib'import { AttributeType, BillingMode, Table } from 'aws-cdk-lib/aws-dynamodb'

Note in the above snippet we specify the billingMode and partitionKey. We also specify the removalPolicy to DESTROY instead of RETAIN. This means when we destroy our CDK project at the end, this table will be removed as well.

Creating our API with an API Key

Next, we’ll need to configure an AppSync API to make use of it.

At the time of this post, the L2 constructs for AppSync are not part of the official CDK library. As such, we’ll install the package separately to make use of them.

To install the alpha L2 constructs for AppSync, run the following command:

npm install @aws-cdk/aws-appsync-alpha

Back in your project, import the followig modules:

import {
    GraphqlApi,
    Schema,
    AuthorizationType,
    FieldLogLevel,
    MappingTemplate,
} from '@aws-cdk/aws-appsync-alpha'import * as path from 'path'

Above, in addition to the AppSync imports, the path module is imported to pull in our GraphQL schema.

The following code snippet is used to create a new AppSync API.

const api = new GraphqlApi(this, 'User API', {
  name: 'User API',
  schema: Schema.fromAsset(path.join(__dirname, 'schema.graphql')),
  authorizationConfig: {
    defaultAuthorization: {
      authorizationType: AuthorizationType.API_KEY,
      apiKeyConfig: {
        description: 'public key for getting data',
        expires: Expiration.after(Duration.days(30)),
        name: 'API Token',
      },
    },
  },
  logConfig: {
    fieldLogLevel: FieldLogLevel.ALL,
  },
  xrayEnabled: true,
})

Notable callouts:

  • defaultAuthorization: Specifies what kind of authorization to use by default. Requests that don’t specify an authMode property will default to an API Key.
  • expires: Sets the key to be expired after 30 days. Note that a static date may be more beneficial as each deploy will attempt to recalculate the timestamp.
  • logConfig: Useful in development. This logs all requests to AWS CloudWatch
  • xrayEnabled: This flag allows tracing the requests using AWS Xray.
  • schema: Uses the file path provided to import our schema.graphql file.
type Query {
    listUsers(limit: Int, nextToken: String): UserConnection
}
type User {
    userId: ID!
    firstname: String!
    lastname: String!
    picture: AWSURL!
}
type UserConnection {
    items: [User!]
    nextToken: String
}

This API is simple in the sense that it doesn’t have a Mutation and only contains one Query field: listUsers.

The listUsers field will SCAN our User table and return a list of users. Optionally, if a limit is specified, a subset of results will be returned along with a token if there are more items.

Lastly, if a nextToken is specified, the results will begin at that position.

 The combination of specifying a limit and nextToken is how pagination works when using AppSync and DynamoDB

With our API’s schema defined, resolvers can be defined so our requests correctly map to a DynamoDB SCAN operation and back to AppSync.

In the CDK stack, you can use the following code:

const userTableDatasource = api.addDynamoDbDataSource('userTableDS', userTable)

userTableDatasource.createResolver({
  typeName: 'Query',
  fieldName: 'listUsers',
  requestMappingTemplate: MappingTemplate.fromFile(
    path.join(__dirname, 'mappingTemplates/Query.listUsers.req.vtl')
  ),
  responseMappingTemplate: MappingTemplate.fromFile(
    path.join(__dirname, 'mappingTemplates/Query.listUsers.res.vtl')
  ),
})

As you can see the L2 constructs make attaching data sources fairly straightforward.

The code snippet assumes the creation of a directory called mappingTemplates and two files: one called Query.listUsers.req.vtl and another called Query.listUsers.res.vtl (note the subtle difference in file names).


#set( $limit = $util.defaultIfNull($ctx.args.limit, 200) )
#set( $scanUsersRequest = {
  "version": "2018-05-29",
  "operation": "Scan",
  "limit": $limit
})
#if( $ctx.args.nextToken )
  #set( $scanUsersRequest.nextToken = $ctx.args.nextToken )
#end
$util.toJson($scanUsersRequest)

The above VTL builds a JSON object at the end that contains the value for $scanUsersRequest. This object is in a format that DynamoDB uses to perform a scan operation. Next in the Query.listUsers.res.vtl file, paste in the following:

{
  "items": $utils.toJson($ctx.result.items)
  #if( ${ctx.result.nextToken} )
      ,"nextToken": $util.toJson($ctx.result.nextToken)
  #end
}

A small document that simply says “give me all the requested items and put them under an items key, and optionally a nextToken if there is one. Recall that this format matches what we specified for the UserConnection return value.

Deploying our backend

Before deploying the backend stack, add the following code snippet to the guest-user-backend-stack.ts file:

//...prev code 
new CfnOutput(this, 'GraphQLAPIID', {
  value: api.apiId,
})
new CfnOutput(this, 'GraphQLURL', {
  value: api.graphqlUrl,
})
new CfnOutput(this, 'GraphQLAPIKey', {
  value: api.apiKey || '',
})

We’ll use those values in our frontend.

In your terminal, run the following code:

npx aws-cdk deploy --outputs-file ./frontend-values.json

Once deployed, you should have a file called frontend-values.json that looks like the following:

{
  "GuestUserBackendStack": {
    "GraphQLAPIID": "zobkbfev7vg63bkgg57z6sig",
    "GraphQLAPIKey": "da2-ze45yo5nm5dttnnsvkyoxwb",
    "GraphQLURL": "https://abcdefg.com/graphql
  }
}

Testing in a Frontend Application

Before testing this end-to-end solution, please refer to the completed backend repo so create the Lambda function that populates the database.

Instead of testing our application with the AppSync console, run the following command to clone the frontend repo:

git clone git@github.com:focusOtter/appsync-apikey-pagination-frontend.git

Once cloned, install the needed dependencies by running the following command:

npm install

In addition to this being a NextJS application, this project makes use of Amplify Libraries, which allow us to easily interact with our backend. Also, the UI of our frontend is created using Amplify’s UI primitives.

Assuming that our Lambda function has had some time to add a few items to our User table, to pull in those values we just need to change the values located in the _app.js file as shown here to those that were printed out in our backend.

To verify everything is setup, run the following command in our terminal to start the app:

npm run dev

random user api data

Cleanup

Because this application has a CRON job associated with it, after verifying everything is working, be sure to destroy the stack by running the following command:

npx aws-cdk destroy

Conclusion

In this application we talked about how API Keys can be created and passed to an AppSync API to allow public access. This allows for rapid development and quick iteration on the GraphQL schema, resolvers, and frontend integration.

In addition, we saw how the AWS Amplify JavaScript libraries allow teams to make use of Amplify à la carte as opposed to its full suite of services.

Past simply using an API Key, more robust applications can make use of multiple authorization modes. If wanting to explore an end-to-end solution that uses other authorization modes, please see the post on Building a full-stack chat application with AWS and NextJS.