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.
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
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 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
}
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)"
}
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"
}
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"]
}
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'
}];
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
}
}
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):
"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
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
Top comments (4)
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.
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!
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.
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.