DEV Community

Arpad Toth for AWS Community Builders

Posted on • Updated on • Originally published at arpadt.com

Using Cognito groups to control access to API endpoints

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 AWS products, so it seems reasonable to use Amazon Cognito for setting up the authentication flow and access control.

Control access to API with Cognito groups - movies

We will store user data in a Cognito user pool, 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 HTTP API created with API Gateway. 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 Lambda 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

  • an HTTP API
  • Lambda functions
  • backend Lambda integrations and Lambda authorizers for the API
  • a Cognito user pool with hosted UI, Cognito domain and callback URL.

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 React app 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:

{
  "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
}
Enter fullscreen mode Exit fullscreen mode

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:

{
  "Authorization": "Basic Base64(CLIENT_ID:CLIENT_SECRET)"
}
Enter fullscreen mode Exit fullscreen mode

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:

{
  "id_token": "ID TOKEN",
  "access_token": "ACCESS TOKEN",
  "refresh_token": "REFRESH TOKEN",
  "expires_in": 3600, // default value of 1 hour
  "token_type": "Bearer"
}
Enter fullscreen mode Exit fullscreen mode

Both the ID and access tokens are JSON Web Tokens (JWT) 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:

{
  "cognito:groups": ["movies-group"]
}
Enter fullscreen mode Exit fullscreen mode

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:

const mapGroupsToPaths = [{
  path: '/movies',
  group: 'movies-group'
}, {
  path: '/shows',
  group: 'shows-group'
}];
Enter fullscreen mode Exit fullscreen mode

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:

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

"cognito:groups": [
  "movies-group",
  "shows-group"
]
Enter fullscreen mode Exit fullscreen mode

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

Working with HTTP APIs - Official documentation about HTTP API

Choosing between REST APIs and HTTP APIs - Comparison with lots of tables

Working with AWS Lambda proxy integrations for HTTP APIs - Adding a Lambda integration to the HTTP API

Building Lambda functions with Node.js - How to create and deploy a Lambda function

Getting started with user pools - How to create a user pool with an app client

Latest comments (4)

Collapse
 
aussiearef profile image
aussiearef

This approach has a fundamental problem which is being against the Open-Closed OOD Principle. Every time a new page or route/path is added to the UI the Lambda Authorizer must be changed and re-deployed to cater for the new page/route.
A good approach might be that every time a client calls the API it also indicates what role or group must the token include. For example https://mydomain.com/get?token=blah&group=admin.

Then in the Lambda code you can read the cognito:groups claim and see if the "admin" role is there.

Collapse
 
arpadt profile image
Arpad Toth

You're right, the map that holds the paths and the corresponding group needs to be changed. But the map doesn't have to be in the Lambda authorizer. In case of many paths/groups, I think it would be more appropriate to have the "array" externally stored. In this case, the authorizer wouldn't need to be redeployed. But it's correct, we'll have to modify the map with this approach. Is this change an extension or modification? In my opinion, it's closer to be an extension but feel free to argue. :)

I like your approach, I think it's a great alternative. In this case, the Lambda function would extract the necessary info from the query and not the map, so the code change would occur on the client side. Thanks for your contribution, appreciate it!

Collapse
 
vavdoshka profile image
Vladimir Avdoshka

awesome, thanks! I wish I found this article earlier.
How do you recommend to handle the change in user group membership?

Like if the user was added or removed from movies-group after login. Context is this is a collab app, where one user (admin) can change the roles (cognito groups) of the other user. I'm trying to see if I can rely on group membership for such RBAC mechanism or I should manage it on my own.
As I understood there should be either a revocation of the refresh token of the affected user to make him re-login and re-issue a new token with new updated claims, or something else I don't know what.

Collapse
 
arpadt profile image
Arpad Toth

Apologies for the late reply. As you pointed out, you can revoke the refresh token: docs.aws.amazon.com/cognito/latest...

To be honest I'm not sure if you can do it without waiting for the access token to expire or forcing the user to log out and log in again. This solution uses a custom Lambda authorizer with no roles since the focus is not on accessing AWS services. If you assign roles to the Cognito groups, then a Cognito authorizer in API Gateway would probably be better, but that's a different use case.

If you have found a solution to your problem in the meantime, I would appreciate it if you shared it with us.