Front-End Web & Mobile

Secure AWS AppSync with Amazon Cognito using the AWS CDK

In a previous post, we explored how a public API Key for AWS AppSync provides a simple way of allowing access to resources, however, it does come with the following tradeoffs:

  1. Expiring after a certain amount of time.
  2. The key is passed on the x-api-key header of the request. Making it easy for bad actors to abuse the API.
  3. Not having a concept of roles, or differentiating permissions.

When configuring an Amazon Cognito identity pool with an unauthenticated role, we can solve for the above concerns while still allowing guests to interact with our backend services without needing a login mechanism.

This post will provide an overview of AWS  IAM permissions as they relate to Cognito identity pools. Also, we’ll learn how to configure an AppSync API to use IAM permissions in the CDK, and Cognito user pools for token-based access. Finally, we’ll discuss how to enable a frontend application can make protected queries and mutations.

By having control over what data is protected and what is kept public, more complex applications can be built. This is further showcased in the followup to this post: Managing images in your NextJS app with AWS AppSync and the AWS CDK.

Understanding IAM permissions

To best grasp how and when IAM permissions are useful, we have to understand some of the underlying pieces that it takes to get them to work.

For a user or AWS service to talk to another service, IAM permissions need to grant that action.

This could be a AWS Lambda function calling an Amazon DynamoDB API or as we’ll see in this post, a guest user wanting to call our AppSync API.

The point is if a guest user wants access to our data, but doesn’t provide an API Key and isn’t part of our userpool, we can still authorize them by providing short-lived, temporary credentials. However, in order to do that, we’ll have to set up an Amazon Cognito identity pool.

Where a Cognito user pool serves as a directory for who has authenticated in our application, a Cognito identity pool allows us to determine what kind of authorization they have.

To expand on this, user pools enable the following access patterns:

  1. Getting data for all logged-in users
  2. Getting data for users in a particular group
  3. Getting data for a single user, based on their ID.

The access token from Cognito provides this information as claims.

In contrast, Cognito identity pools have a concept of users or services and each makes use of either an unauthenticated or authenticated role.

While at face value this may seem limited, the benefit is that both types of identities can be given access to our services.

Project Setup

As mentioned, this project will make use of the same backend repository from the previous blog post. To continue from that project, run the following command:

git clone git@github.com:focusOtter/appsync-cdk-cognito-fullstack.git

Once in the project directory, install the necessary packages by running the following command:

cd backend && npm i

This project is set up such that it contains an API that scans a User table for user information. That data is inserted via a Lambda function that runs on a CRON schedule.

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

With our project setup, open up the project in your code editor and begin working in the lib/guest-user-backend-stack.ts file.

Amazon Cognito for authentication

This project makes use of the L2 constructs for creating an identity pool. This package is not yet part of the official CDK library. This package can be viewed in your package.json file.

Configuring a frontend application to make use of a Cognito identity pool requires a user pool to be created. As such, an added benefit of this solution is that if we needed to have customers sign in do a certain part of our frontend, doing so becomes trivial.

import { AccountRecovery, UserPool, UserPoolClient, VerificationEmailStyle } from 'aws-cdk-lib/aws-cognito'

import {
    IdentityPool,
    UserPoolAuthenticationProvider,
} from '@aws-cdk/aws-cognito-identitypool-alpha'

Next, directly underneath the call to super(), review the following code that creates a userPool.

const userPool = new UserPool(this, 'UserDemoPool', {
  selfSignUpEnabled: true,
  accountRecovery: AccountRecovery.PHONE_AND_EMAIL,
  userVerification: {
    emailStyle: VerificationEmailStyle.CODE,
  },
  autoVerify: {
    email: true,
  },
  standardAttributes: {
    email: {
      required: true,
      mutable: true,
    },
  },
})
const userPoolClient = new UserPoolClient(this, 'UserDemoPoolClient', {
  userPool,
})
const identityPool = new IdentityPool(this, 'IdentityDemoPool', {
  identityPoolName: 'identityDemoForUserData',
  allowUnauthenticatedIdentities: true,
  authenticationProviders: {
    userPools: [
      new UserPoolAuthenticationProvider({ userPool, userPoolClient }),
    ],
  },
})

The first portion of the code is used to create our Cognito user pool. If just wanting a way for users to sign in to your application, this is the only piece of Cognito needed.

For posterity, we’ll set up a Cognito as the default authorization mode since that’s common in many applications. However, we’ll set IAM as a secondary authorization type.

To allow guests to receive their temporary credentials, we set the allowUnauthenticatedIdentities flag to trueon the identity pool.

In addition, we tie our identity pool to our user pool by adding it as an authenticationProviders.

AppSync API with Amazon Cognito

As mentioned, we’ll set Cognito user pools as the default authorization type, and allow unauthenticated IAM users to call the listUsers Query on our schema. To accomplish this, note the current API construct with the following:

const api = new GraphqlApi(this, 'User API', {
  name: 'User API',
  schema: SchemaFile.fromAsset(path.join(__dirname, 'schema.graphql')),
  authorizationConfig: {
    defaultAuthorization: {
      authorizationType: AuthorizationType.USER_POOL,
      userPoolConfig: {
        userPool,
      },
    },
    additionalAuthorizationModes: [
      {
        authorizationType: AuthorizationType.IAM,
      },
    ],
  },
})

api.grantQuery(identityPool.unauthenticatedRole, 'listUsers')

The power of the L2 AppSync construct, and the L2 Identity Pool construct combined means that we can add the ability to query our API to our unauthenticated role in an elegant and readable way.

AppSync Schema

The last part before deploying our application is to update our schema so that it is aware of what types of authorization it should check for.

Review the current schema:

type Query {
  listUsers(limit: Int, nextToken: String): UserConnection
      @aws_iam
      @aws_cognito_user_pools
}

type User @aws_iam {
  userId: ID!
  firstname: String!
  lastname: String!
  picture: AWSURL!
}

type UserConnection @aws_iam {
  items: [User!]
  nextToken: String
}

This schema shows a few authorization directives that tell AppSync what access permissions to allow. Because Cognito user pools are the default authorization type, applying the directive to the listUsers query will automatically cascade down to its nested fields so long as they are not part of a separate table.

In contrast, because IAM is a secondary auth type, we must manually cascade the directive.

Deploying our application

To deploy the application, run the following command:

npx aws-cdk deploy —outputs-file ./frontend-config.json

Once deployed, you should have a config file generated for you that looks similar the following:

{
  "GuestUserBackendStack": {
    "UserPoolClientId": "zyxw9876",
    "GraphQLAPIID": "abcd1234",
    "UserPoolId": "us-east-1_OUC1dLipV",
    "GraphQLURL": "https://abcdefg.appsync-api.us-east-1.amazonaws.com/graphql",
    "GraphQLAPIKey": "012345",
    "IdentityPoolId": "us-east-1:9829a415-d241-4862-91f1-1111111111"
  }
}

These are the values that we’ll need on our frontend.

Testing in our frontend

To get up and running in one step, update the frontend values:

git clone git@github.com:focusOtter/appsync-apikey-pagination-frontend.git && cd $_ && git checkout iam-with-cognito-api && npm install

That long script will clone the repo, change into the directory, checkout the correct branch, and install the dependencies.

From there, change the values located in pages/_app.js so that the values match what was output from our CDK backend:

Amplify.configure({
	Auth: {
		region: 'us-east-1',
		userPoolId: 'us-east-1_OUC1dLipV',
		userPoolWebClientId: '49ma003d3ersievorc9jjbd1sh',
		identityPoolId: 'us-east-1:9829a415-d241-4862-91f1-5496387470be',
	},
	aws_project_region: 'us-east-1',
	aws_appsync_graphqlEndpoint:
		'https://ke2ctwgkojfu3bwolazncxb5jm.appsync-api.us-east-1.amazonaws.com/graphql',
	aws_appsync_region: 'us-east-1',
	aws_appsync_authenticationType: 'AMAZON_COGNITO_USER_POOLS',
	aws_appsync_apiKey: 'da2-a6a74qu5k5a6tk2kwx5ebp23ca',
})

When specifying the aws_appsync_authenticationType field, note that only the default auth type is needed.

Lastly, in each of the API calls to fetchUserQuery, set the authMode to AWS_IAM

Start the application by running the following:

npm run dev

Assuming enough time has elapsed, you should see something similar to the following:

random user api data

Feel free to perform a smoke test by switching the authMode to API_KEY to see the request fail with a 401(unauthorized) message.

Enabling User Sign Up with Amplify UI Components

Our backend is already configured to accept user sign up and sign in because we created a user pool. The frontend is also aware of our user pool since the details are part of the Amplify.configure() method.

All that’s left is to display a auth screen. While Amazon Cognito does come with its own hosted UI, Amplify has its own set of UI components that offer a more theme-able and feature-rich experience.

In fact, in the _app.js file, the AmplifyProvider component is already being used to allow theming throughout our application. To authenticate an entire page, we can make use of the withAuthenticator component in the pages/index.js file. This will not only protect that page with a sign in screen, but also add the ability to signup, and send an email in case the password is forgotten.

In pages/index.js make the following changes:

function Home () {
//rest of component code
}

export default withAuthenticator(Home)

Next update the API’s authMode  so that it no longer uses IAM:

API.graphql({
  query: fetchUsersQuery,
  variables: {
    limit: 5,
    nextToken: currToken,
  },
  authMode: 'AWS_IAM', // remove this line
})

Once that change is made, refresh the application and you will be presented with the following screen and after signing in, will still be able to see the list of users despite using a different authorization strategy.

withauthenticator componentCleanup

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 post we discussed how to tighten the authorization strategy on our AppSync API by using Amazon Cognito and IAM permissions. In addition, by only using the AWS Amplify JavaScript libraries on the frontend in addition to the Amplify UI components, we saw how frontend teams can use Amplify à la carte, as opposed to its entire suite of services.

Understanding these concepts both in isolation and in combination not only helps build out more complex applications as shown in the Building a full-stack chat application with AWS and NextJS post but also enables fullstack teams to prioritize security without compromising on velocity.

To learn more about AWS AppSync and its rich set of features, refer to the service page.