Securing AWS API Gateway with Cognito Authorizer and OAuth 2.0

Photo by marcos mayer on Unsplash

If you have used AWS Cognito, you will know that it’s a simple(ish) yet effective solution for managing your users and authentication. Most applications we build on the internet require some flavour of authentication, the most common being the traditional client-side username and password flow. With a little configuration, Cognito can handle most of the underlying authentication mechanisms for us, so we can focus on delivering the features our customers care about most.

It turns out that Cognito is a strong contender for handling server-side authentication too. Suppose we have an API that serves content restricted to our backend services, where there is no customer on either end of the request. Cognito provides solutions for handling the handshake between such services by leveraging the OAuth 2.0 protocol.

In this article, we will cover controlling server-side access to API Gateway resources by utilising the client credentials OAuth 2.0 flow available in Cognito, using custom scopes and a Cognito Authorizer.

About the Project

Everything you need to follow along has been conveniently pre-written and placed in this GitHub repository which contains the CloudFormation template that Serverless Framework will deploy to AWS. You won’t need to write any code to follow along, just a few commands in your CLI.

The follow-along checklist

  • An AWS Account
  • AWS Access Key — Allows Serverless Framework to access your AWS instance to deploy infrastructure. Check out this guide if you need help setting up.
  • Node.js — Required to install the Serverless Framework dependency using the package.json file included in the repository.

Note: if you are currently on the AWS free tier, this won’t cost you anything. If not, you may be billed a very small amount. The sample project is fully serverless so you’ll only pay for what you use. Remove the infrastructure once you are finished to avoid costs.

Included in the Project

We’re going to deploy a simple weather events API with 2 endpoints. There’s an endpoint for creating a weather event and another for retrieving all weather events. The endpoints have mock integrations for simplicity. A Cognito Authorizer will front each endpoint and require different custom scopes for controlling authorisation. We will play around with the custom scopes to gain an understanding of the effects they have on the calls to the API.

Cognito User Pool

This is where your customers’ info is stored once they register with Cognito. More importantly, the available custom scopes defined by your resource server(s) are also stored here.

The user pool specifies the requirements a user must satisfy to create an account (or user). For example, the required attributes such as username and password validation. There is way more to a user pool than just validation. I’m skipping the detail as we won’t actually be creating or interacting with users in the user pool.

Cognito User Pool Resource Server

A Resource Server stores a list of arbitrary values that describe the actions a consumer can perform on a particular resource (custom scopes). How you name these scopes is up to you, as long as they make sense to you and your team. Be as broad or specific as you like when describing custom scopes. For example, you may want to describe access to a resource by CRUD operations or per endpoint.

The custom scopes from a Resource Server are made available to the user pool. The user pools app client specifies a subset or all of the custom scopes in the user pool to make available to the client’s consumer (the backend service). The consumer requests the custom scopes that the app client should apply to the returned access token upon successful authentication.

Cognito User Pool Client

The user pool client, also known as the app client, is the interface to the user pool. Typically users communicate with the app client using their web browser to perform actions such as signing in, registering, initiating a password reset etc.

The app client must have a client secret associated with it. The secret, along with the client id, forms the basis of trust between Cognito and the backend service in the client credentials OAuth flow. The client secret is immutable once created. Secret rotation can be achieved by creating a new app client and deleting the existing one. Deleting the existing client app will invalidate all associated access tokens currently in use. You can configure multiple app clients, each dedicated to a specific service. This gives greater control when it comes to rotating and revoking access.

The client id and secret are securely stored by the backend service. These credentials are passed to Cognito when calling the /oauth2/token endpoint.

The client’s consumer (the backend service) requests one or more of the available scopes within the app client to be added to the access token. All available custom scopes are added to the access token unless specific custom scopes are requested.

Cognito User Pool Domain

A Cognito user pool domain is required to enable OAuth 2.0 endpoints on the user pool. Specifically, this allows our backend services to authenticate with Cognito via the /oauth2/token URL and obtain an access token.

AWS API Gateway

This is configured as a REST API and will include 2 endpoints with mock integrations. A POST endpoint for creating a new weather event and a GET endpoint to retrieve a list of weather events.

The API itself is defined using an OpenAPI specification file. The specification includes the Cognito Authorizer that handles the authentication and authorisation between the backend service and the API on a per endpoint basis. The Cognito Authorizer specifies the required custom scope(s) that should exist within the given access token to determine whether to grant the caller permission to the endpoint.

Let’s deploy the infrastructure and dive in

Right, first off grab a copy of the weather-events-api project and open it up in your code editor. You can clone or download it as a .zip, whatever floats your boat.

Take a look at the project files to familiarize yourself with the services we will be deploying. You may need to change the AWS deployment region by updating the awsRegion parameter at the top of the serverless.yml file.

Build and Deploy

Deploy the Cloudformation stack to our AWS instance by running the command as follows.

# Install the serverless node dependency and deploy the stack to AWS
❯ yarn && yarn serverless deploy

Cognito Authentication

Authenticate with Cognito using the user pool domain specifying the client credentials grant type and pass the app client, client ID and client secret.

The domain can be found in the AWS Console > Cognito > User pools > App Integration > Domain.

The Client ID and Secret is found in Cognito > User pools > App clients and analytics > CognitoUserPoolServiceClient… > App client information.

❯ curl --location --request POST "USERPOOL_DOMAIN/oauth2/token" \
--header "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode "grant_type=client_credentials" \
--data-urlencode "client_id=COGNITO_CLIENT_ID" \
--data-urlencode "client_secret=COGNITO_CLIENT_SECRET"

Inspecting the Access Token

At this point, we should have received a successful response back containing our access token. Copy the access token and paste it into jwt.io. Notice that the scope of the token lists all of the custom scopes available to the Cognito app client. Also, observe how the weather-api/delete scope is missing. This is because we did not specify it as an available scope in the app client configuration and is therefore inaccessible to us.

Authenticated & Authorised API Request

Using the access token from the previous step, make a GET request to your weather API. You’ll find the API_GATEWAY_URL by going to the AWS Console > API Gatway > APIs > Weather Events REST API > Stages > weather. You’ll see Invoke URL, copy that.

❯ curl -i API_GATEWAY_URL \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ACCESS_TOKEN"

We will recieve a successful JSON response from the API with the following data.

{"events": [ {"name": "Storms Dudley, Eunice and Franklin", "dateStart": "2022-02-16", "dateEnd": "2022-02-21" }, {"name": "Storms Malik and Corrie", "dateStart": "2022-01-29", "dateEnd": "2022-01-31" } ]}

This request succeeds because the access token is valid, and the scope required to access the API Resource exists on the access token. Behind the scenes, the Cognito Authorizer attached to the API Gateway determines the validity and grants of the access token by communicating with Cognito and the Resource Server.

Authenticated but Unauthorised API Request

Let’s find out what happens if we try to make a request to the weather API whilst missing the required custom scope from the access token.

In your CLI, enter the command to authenticate with Cognito. You will notice that we are explicitly specifying the scope of the access token this time. Paste the access token into jwt.io. You will see a single custom scope on the access token, the custom scope we specified. I mentioned earlier that if you don’t explicitly request a custom scope, the app client will add all of its available custom scopes to the access token. A lot of the time this is fine, other times we want to control permissions to API resources for different callers. Limiting custom scopes is a great way to achieve this as we’ll find out.

❯ curl --location --request POST 'USERPOOL_DOMAIN/oauth2/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'client_id=COGNITO_CLIENT_ID' \
--data-urlencode 'client_secret=COGNITO_CLIENT_SECRET'
--data-urlencode 'scope=weather-api/post'

Make a call to the weather API as we did before. This time update the access token with the new access token you just received.

❯ curl -i API_GATEWAY_URL \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ACCESS_TOKEN"

You should now see an unautorised response. This happens because the access token doesn’t have the required scope to access the GET resource on the weather API. The Cognito Authorizer specifically requires the weather-api/get scope to exist on the access token.

Using the same access token, make a POST request to the weather API.

❯ curl -i --request POST API_GATEWAY_URL \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer ACCESS_TOKEN"

You will receive a successful response with the data you requested. The reason is the same as the first successful request we made. The required custom scope exists on the access token that the Cognito Authorizer requires.

Request an Unavailable Scope

So what happens if we try to request a custom scope that isn’t available to the app client? Try running the same command to authenticate with Cognito, this time changing the scope to weather-api/delete.

❯ curl --location --request POST 'USERPOOL_DOMAIN/oauth2/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode 'client_id=COGNITO_CLIENT_ID' \
--data-urlencode 'client_secret=COGNITO_CLIENT_SECRET'
--data-urlencode 'scope=weather-api/delete'

In this case, we recieve an error informing us that the grant isn’t valid. Although this custom scope is available to the user pool, we did not make it available to the app client in the Cloudformation template. So this custom scope is unavailable to us as the caller.

{"error":"invalid_grant"}

Cleanup

One of the nice things about writing IaC is the speed of creating and removing services. To remove the services we’ve added, run the following command within the project directory.

yarn sls remove

Final Tip

As a best practice, you should implement a strategy to re-use the access token for as long as it’s valid. You may be tempted to call the /oauth2/token endpoint before every call to your API. This works when your predicted traffic is low. However, it doesn’t scale well and adds unnecessary latency to your system. Be sure to examine the Cognito usage quotas and consider the best approach in your solution to avoid throttling, or worse, an AWS design review 😱. Calls to the /oauth2/token endpoint are limited to 150 requests per second at the time of writing this article.

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store

Veygo Engineering

A software development blog by the folks at veygo.com