Using Cognito groups to control access to API endpoints
Tags: aws, security, serverless
Published: 2022-09-22 06:43:03

User groups in Cognito provide a simple way to control access to different endpoints. It's a serverless solution that we can set up in a few minutes.

## 1. Scenario

It's a common scenario that the users of an application should access different endpoints based on their permission level.

Example Corp. has a movie application where users can decide if they want to get information about **movies** or **shows** based on their preference.

Our users will have a username and password. They will then log in to the application and access either the `GET /movies` or the `GET /shows` endpoint. Users in the `movies-group` can access the `/movies` endpoint while `/shows` will deny their request. Similarly, `shows-group` users will get a successful response from the `/shows` endpoint but will be disappointed when they want to get the `movies`.

## 2. Solution overview

The company has built their system using <a href="https://aws.amazon.com/" class="link" target="_blank" rel="noopener noreferrer">AWS</a> products, so it seems reasonable to use <a href="https://aws.amazon.com/cognito/" class="link" target="_blank" rel="noopener noreferrer">Amazon Cognito</a> for setting up the authentication flow and access control.

![Control access to API with Cognito groups - movies](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/audumpmudcocfat81gw2.png)

We will store user data in a Cognito <a href="https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools.html" class="link" target="_blank" rel="noopener noreferrer">user pool</a>, which has two groups, `movies-group` and `shows-group`. User Alice likes movies, and she will be in the `movies-group`. On the other hand, Bob prefers shows, and we will place him into the `shows-group`.

When the user logs in to the application with their username and password, Cognito will return a one-time **token** as a query string in the **callback URL**. The application will then exchange this code for the **tokens** that contain the **group** information.

The application will then call an <a href="https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api.html" class="link" target="_blank" rel="noopener noreferrer">HTTP API</a> created with <a href="https://aws.amazon.com/api-gateway/" class="link" target="_blank" rel="noopener noreferrer">API Gateway</a>. The API has two endpoints, `GET /movies` and `GET /shows`, which return exciting information about movies and shows, respectively.

We will protect both endpoints with a custom authorizer, which is a <a href="https://aws.amazon.com/lambda" class="link" target="_blank" rel="noopener noreferrer">Lambda</a> function. The authorizer will verify, decode and extract the group information from the token, and allows or denies the request.

## 3. Details

Let's take a closer look at some of the steps in this pattern.

### 3.1. Pre-requisites

This post won't explain how to create

<ul class="list">
  <li class="list__item">an HTTP API</li>
  <li class="list__item">Lambda functions</li>
  <li class="list__item">backend Lambda integrations and Lambda authorizers for the API</li>
  <li class="list__item">a Cognito user pool with hosted UI, Cognito domain and callback URL.</li>
</ul>

I'll provide some links at the end of the post that will help spin up these resources if needed.

### 3.2. Creating users and groups

Let's create two users, Alice and Bob, and assign them passwords in the Cognito user pool. We will also need two groups, `movies-group` and `shows-group`. Add Alice to the `movies-group` and Bob to the `shows-group`.

### 3.3. User login

Let Alice sign in by entering the following address into the browser:

```
https://USER_POOL_NAME.auth.us-east-1.amazoncognito.com/login?
  response_type=code&
  client_id=APP_CLIENT_ID&
  redirect_uri=http://localhost:3000/app
```

`response_type=code` means we want an authentication code in the response and no tokens. It is more secure than making the token visible to the user in the browser.

We should also specify the app `client_id`. We generated the app client when we were creating the user pool. The app client will call the authorization server on our behalf, so we have to define its id in the request.

The last part of the URL is the `redirect_uri`. It's the same as we specified when we created the user pool. In this case, let's make it `http://localhost:3000/app`.

Calling this URL from the browser will redirect us to the hosted UI. Here we can enter Alice's username and password.

Cognito will ask us to change the password, and then it will redirect us to `localhost:3000/app`, which is the **redirect URL** that we specified when we created the user pool.

The redirect URL in the browser's address bar will look like this:

```
http://localhost:3000/app?
  code=463e3e32-d19d-4c51-9010-a56361232e89
```

As we can see, Cognito has appended the authorization code to the redirect URL.

### 3.4. Running an application on localhost:3000

I just span up a quick <a href="https://create-react-app.dev/" class="link" target="_blank" rel="noopener noreferrer">React app</a> and created the `/app` page. If you start the app with `npm start`, it will display the landing page on `localhost:3000`, so Cognito can redirect the user to `localhost:3000/app`.

### 3.5. Getting the tokens

The problem is that API Gateway won't understand the authorization code. We will have to get a **token** instead and submit the request with it.

We can exchange the authorization code to **ID**, **access** and **refresh** tokens.

This process occurs at the application level and not in the browser. This way, users can't see the tokens, which adds an extra layer of security to the process.

The application extracts the authorization code from the URL and makes a `POST` request to the `https://USER_POOL_NAME.auth.us-east-1.amazoncognito.com/oauth2/token` endpoint. The body of the request should be in `x-www-form-urlencoded` format and must have the following payload:

```jsonc
{
  "grant_type": "authorization_code",
  "code": "463e3e32-d19d-4c51-9010-a56361232e89", // authorization code
  "client_id": "APP CLIENT ID HERE",
  "redirect_uri": "http://localhost:3000/app" // same as above
}
```

If we added a secret to the app client when we created it, we must include both the client id and the secret in the request. We should send them Base64 encoded in `CLIENT_ID:CLIENT_SECRET` format in the `Authorization` header:

```json
{
  "Authorization": "Basic Base64(CLIENT_ID:CLIENT_SECRET)"
}
```

If there's no secret added to the app client (recommended for web applications), we won't have to add the `Authorization` header.

We can use the authorization code only once. If we try to submit the request with the same code again, we will get an `invalid grant` error.

We can now simulate the flow by firing the request from Postman. The response should look like this:

```jsonc
{
  "id_token": "ID TOKEN",
  "access_token": "ACCESS TOKEN",
  "refresh_token": "REFRESH TOKEN",
  "expires_in": 3600, // default value of 1 hour
  "token_type": "Bearer"
}
```
Both the ID and access tokens are <a href="https://jwt.io/" class="link" target="_blank" rel="noopener noreferrer">JSON Web Tokens (JWT)</a> and contain the group information as a claim. I will cover the difference between ID and access tokens in another article.

The claim we are most interested in is the `cognito:groups`, which will be an array:

```json
{
  "cognito:groups": ["movies-group"]
}
```

We can use this information to control access to the backend endpoints.

### 3.6. Backend - authorizer code

Let's highlight some parts of the custom authorizer code.

As discussed earlier, we will have a Lambda authorizer that verifies the token and decides if the requested path (`/movies` or `/shows`) belongs to the user's Cognito group.

First, we can create an array of objects that map the groups to the paths:

```js
const mapGroupsToPaths = [{
  path: '/movies',
  group: 'movies-group'
}, {
  path: '/shows',
  group: 'shows-group'
}];
```

We can store this information in an external database and fetch it from there. For the sake of simplicity, and because this example has only two routes, let's store the map in memory.

The handler of the authorizer function can look like this:
```js
const { CognitoJwtVerifier } = require('aws-jwt-verify');

exports.handler = async function(event) {
  // get the requested path from the API Gateway event
  const requestPath = event.requestContext.http.path
  const existingPaths = mapGroupsToPaths.map((config) => config.path)
  if (!existingPaths.includes(requestPath)) {
    console.log('Invalid path')
    return {
      isAuthorized: false
    }
  }

  const authHeader = event.headers.authorization
  if (!authHeader) {
    console.log('No auth header')
    return {
      isAuthorized: false
    }
  }

  // header has a 'Bearer TOKEN' format
  const token = authHeader.split(' ')[1]

  // the package verifies the token
  // specify if you want to verify ID or access token
  const verifier = CognitoJwtVerifier.create({
    userPoolId: 'USER POOL ID',
    tokenUse: 'access', // or tokenUse: 'id' for ID tokens
    clientId: 'APP CLIENT ID',
  });

  let payload
  try {
    payload = await verifier.verify(token);
    console.log('Token is valid. Payload:', payload);
  } catch {
    console.log('Token not valid!');
    return {
      isAuthorized: false
    }
  }
  
  // header has a 'Bearer TOKEN' format
  const matchingPathConfig = mapGroupsToPaths.find(
    (config) => requestPath === config.path
  )
  const userGroups = payload['cognito:groups']
  if (userGroups.includes(matchingPathConfig.group)) {
    return {
      isAuthorized: true
    }
  }

  return {
    isAuthorized: false
  }
}
```

The **aws-jwt-verify** package verifies the signature and decodes the token with just one line of code. Its `verify` method returns the payload of the decoded token. We must specify if we want to use an ID (`tokenUse: 'id'`) or an access token (`tokenUse: 'access'`). If we call the endpoint with a different type of token from what we have specified in the authorizer code, we will receive an invalid token error. Although the ID and access tokens contain the group information, too, we will use the **access token** in this example.

The custom authorizer can **return** either an object or a policy for HTTP APIs. The returned object should be `{ isAuthorized: true/false }` depending on the result of the authorization. Returned policies are the same as in the case of a REST API. I found returning the object easier than generating and responding with a policy.

### 3.7. It should work!

We can now test if Alice and Bob can get a valid response for their movie and show inquiry.

We should have Alice's tokens, so let's put the access token in the `Authorization` header and call the endpoint from Postman:

```
GET https://API_GW_INVOKE_URL/movies
```

We should get a valid response because we assigned Alice to the `movies-group` in Cognito.

If we try calling the `/shows` endpoint, we will get a `403 Forbidden` error.

If we sign in with Bob's credentials, we will receive a successful response for the `/shows` endpoint and `403` for the `/movies`.

### 3.8. A user can be in multiple groups

If Alice decides to extend her interests and wants to start watching shows, we can add her to the `shows-group` in Cognito. Alice is now in both groups, and her access token will reflect that (she will need to sign in again):

```jsonc
"cognito:groups": [
  "movies-group",
  "shows-group"
]
```

She can now receive success responses from both the `/movies` and `/shows` endpoints.

## 4. Summary

We can create groups in Cognito and add users to the groups. Cognito will place the group information on the ID and access tokens.

If we have an HTTP API with our endpoints, we can use a custom authorizer that verifies the token. The Lambda authorizer can extract the group information from the token payload and return a response object with the authorization result.

## 5. Further reading

<a href="https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api.html" class="link" target="_blank" rel="noopener noreferrer">Working with HTTP APIs</a> - Official documentation about HTTP API

<a href="https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-vs-rest.html" class="link" target="_blank" rel="noopener noreferrer">Choosing between REST APIs and HTTP APIs</a> - Comparison with lots of tables

<a href="https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-develop-integrations-lambda.html" class="link" target="_blank" rel="noopener noreferrer">Working with AWS Lambda proxy integrations for HTTP APIs</a> - Adding a Lambda integration to the HTTP API

<a href="https://docs.aws.amazon.com/lambda/latest/dg/lambda-nodejs.html" class="link" target="_blank" rel="noopener noreferrer">Building Lambda functions with Node.js</a> - How to create and deploy a Lambda function

<a href="https://docs.aws.amazon.com/cognito/latest/developerguide/getting-started-with-cognito-user-pools.html" class="link" target="_blank" rel="noopener noreferrer">Getting started with user pools</a> - How to create a user pool with an app client