Controlling access to resources with Cognito groups and IAM roles - Authenticated users in Cognito Identity pools, Part 1

User groups in our applications usually have similar permission sets. We can pair IAM roles with these groups in Cognito to define which actions on which resources can individual users access after they have authenticated.

1. The scenario

Say we have an application where we place users in multiple groups based on their permission sets. I’m not talking about IAM but application users, who sign up, log in and use our application. Those users can be administrators, read-only users, or can belong to other permission categories. I already discussed a way we can use Cognito user pool groups in access control to specific endpoints.

But it’s not the only way we to control access with Cognito groups. We can assign them IAM roles and allow (or deny) the groups’ users access to AWS resources directly from our application. Identity pools will do a large chunk of the job, but tools like AWS SDK can provide a simplified, abstract interface to perform the same logic.

2. A basic architecture

In this example, we’ll have an architecture with Cognito User pools, IAM roles, Identity pools, and DynamoDB.

User pools and Identity pools
User pools and Identity pools

The application’s protected page displays books we store in a DynamoDB table.

testuser1, who we have already assigned to a group called BooksRead in the user pool, signs in the application first. When it happens, Cognito User pools, an identity provider (a database for application users’ credentials and other properties), returns an ID token.

Associate a role with the group
Associate a role with the group

BooksRead is a read-only group with the BooksReadAccess customer-managed IAM Role associated. The role provides - surprise - read but no write access to the DynamoDB table.

The application will then call the GetId API of Identity pools, which returns an IdentityId.

The last step is for the application to call GetCredentialsForIdentity in Identity pools. It will return the BooksReadAccess role’s AWS credentials via Security Token Service (STS). The application can now use the credentials to read items from the DynamoDB table directly without any backend logic.

Let’s see these steps in detail.

3. Authentication - the structure of the ID token

This post won’t cover how to create user pools or user groups in Cognito. I’ll attach links to the References section that explain these concepts in detail.

One thing to be careful about with the group creation is that we should add the IAM role that specifies the permissions. We can also add a precedence for the group. If the user belongs to multiple groups, the permissions from the group with the lowest precedence number will be valid.

After the user has successfully authenticated, the user pool will return an ID token, which is a JSON Web Token (JWT).

The relevant part of the token is the following:

{
  // ...
  "cognito:groups": [
    "BooksRead"
  ],
  "cognito:preferred_role": "arn:aws:iam::123456789012:role/BooksReadAccess",
  "cognito:roles": [
    "arn:aws:iam::123456789012:role/BooksReadAccess"
  ],
  // ...
}

As the snippet above shows, Cognito will add references to the group and the role in the token. The preferred_role claim is essential in this solution since the following steps will build on it.

The roles and the groups claims are arrays. While the arrays will contain all groups (cognito:groups) and roles (cognito:roles) assigned to the user, preferred_role will only specify the one with the lowest precedence, and Cognito will return the credentials for this role at the end of the workflow.

4. Retrieving AWS credentials from Identity Pool

Next, we’ll create an identity pool with Authenticated access, because the user who wants to get the books from the table must sign in to the application (see References for the tutorial).

It is where we refer to the preferred_role claim from the token when we specify the role settings.

Referring to preferred_role in Identity pools
Referring to preferred_role in Identity pools

The identity pool will inspect the ID token and eventually assume the BooksReadAccess role on behalf of the user.

Let’s see how it happens.

4.1. GetId

First, we make a POST request to the GetId API with the following payload:

{
    "IdentityPoolId": "IDENTITY_POOL_ID",
    "Logins":{
        "cognito-idp.eu-central-1.amazonaws.com/USER_POOL_ID": "ID_TOKEN"
    }
}

We should call the Identity pools endpoint for the given region. In this example, it’s https://cognito-identity.eu-central-1.amazonaws.com.

The Logins object contains a property that refers to the identity provider, in this case, Cognito Identity pools. Third-party authentications will require different keys. The GetID API reference page will specify what keys we must define in each case.

We must also specify some headers in the request:

{
  "Content-Type": "application/x-amz-json-1.1",
  "x-amz-target": "com.amazonaws.cognito.identity.model.AWSCognitoIdentityService.GetId"
}

By default Postman adds the Content-Type header to the request based on the body type we select. Requests with this header will return an error, so we should remove it and add the correct header value.

If everything goes well, the endpoint should return the IdentityId, which looks like this:

eu-central-1:RANDOM_ID

It consists of the region and a UUID. We can’t use it as is to access DynamoDB yet because AWS resources always need AWS credentials. Identity pools will exchange this unique ID for them in STS in the next step.

Errors

A frequent error message for the GetId call is The server did not understand the operation that was requested. In my experience, this error occurs in one of the following scenarios:

  • The endpoint is incorrect, i.e., we use User pools regional endpoints instead of Identity pools ones.
  • One or both headers are missing or their values are incorrect.

4.2. GetCredentialsForIdentity

We will finally retrieve the AWS credentials by calling the GetCredentialsForIdentity API. It must also be a POST request, and the payload is very similar to the GetId call:

{
    "IdentityId": "eu-central-1:RANDOM_ID",
    "Logins":{
        "cognito-idp.eu-central-1.amazonaws.com/USER_POOL_ID": "ID_TOKEN"
    }
}

The only difference is that we should replace the IdentityPoolId with the IdentityId we received in the last step.

We should also make a slight modification in the header:

{
  "Content-Type": "application/x-amz-json-1.1",
  "x-amz-target": "com.amazonaws.cognito.identity.model.AWSCognitoIdentityService.GetCredentialsForIdentity"
}

The response should contain the credentials:

{
  "Credentials": {
    "AccessKeyId": "ASIASOMETHING",
    "Expiration": 1.692711472E9,
    "SecretKey": "SECRET_KEY",
    "SessionToken": "SESSION_TOKEN"
  },
  "IdentityId": "eu-central-1:RANDOM_ID"
}

It’s all good! We can now use the credentials to query items from the table!

Errors

The following error can come up when we call the API:

InvalidIdentityPoolConfigurationException. Invalid identity pool
configuration. Check assigned IAM roles for this pool.

When we see this error message, it’s worth checking that the role’s trust policy allows the identity pool:

// BooksReadAccess IAM role
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "cognito-identity.amazonaws.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          // NOT user pool id!!!!
          "cognito-identity.amazonaws.com:aud": "IDENTITY_POOL_ID"
        }
      }
    }
  ]
}

It should work now!

5. React use case

Luckily, the SDK will do the GetId and GetCredentialsForIdentity calls in the background for us, so we don’t have to call the APIs manually.

Using React and Amplify, we can write a very simple component like this:

import { withAuthenticator } from '@aws-amplify/ui-react';
import { DynamoDBClient, QueryCommand } from '@aws-sdk/client-dynamodb';
import { fromCognitoIdentityPool } from '@aws-sdk/credential-providers';
// ... more imports

const REGION = 'eu-central-1';

function App({ user }) {
  const idToken = user.signInUserSession.idToken.jwtToken;

  const ddbClient = new DynamoDBClient({
    region: REGION,
    credentials: fromCognitoIdentityPool({
      clientConfig: { region: REGION },
      identityPoolId: 'IDENTITY_POOL_ID',
      logins: {
        'cognito-idp.eu-central-1.amazonaws.com/USER_POOL_ID':
          idToken,
      },
    }),
  });

  const input = {
    ExpressionAttributeValues: {
      ':author': {
        S: 'AN_AUTHOR',
      },
    },
    KeyConditionExpression: 'author = :author',
    TableName: 'book-table',
  };
  const command = new QueryCommand(input);

  // ... more React code

  return (
    // ... display the book details here
  )
}

export default withAuthenticator(App);

The key elements are the following.

withAuthenticator

Amplify comes with the withAuthenticator method. It allows us to access the user object containing the ID token. We should wrap the component in withAuthenticator before we export it to make it work.

GetID and GetCredentialsForIdentity

We can add a credentials property to the DynamoDBClient constructor. The fromCognitoIdentityPool provider method accepts the same parameters we used in Step 4.1. The client then uses the returned credentials to call the Query API and retrieves the books.

This way, our user can retrieve books but can’t add any to the table because the associated IAM role (BooksReadAccess) doesn’t allow it:

User: arn:aws:sts::123456789012:assumed-role/BooksReadAccess/CognitoIdentityCredentials \
is not authorized to perform: dynamodb:PutItem on resource: \
arn:aws:dynamodb:eu-central-1:123456789012:table/book-table because \
no identity-based policy allows the dynamodb:PutItem action

The above code snippet doesn’t contain the entire logic. The sample component code is for a page the user sees after signing in to the application. We should configure the user pool separately and add the sign-in logic. These parts are beyond the scope of this post.

6. Considerations

We can now access AWS resources from a mobile or web application without creating our backend. The example above is simple because we display book data without applying business logic. It is usually not the case, and we’ll add some business logic to the data before displaying it. The code above is for demonstration purposes only.

We can now see what happens behind the scenes when an authenticated user receives AWS credentials via Identity pools. There might be a use case when we need to apply these steps one by one programmatically, but most of the time we can use the SDK clients in application codes. They abstract these steps away, and we’ll get the same outcome by specifying just a few parameters.

In reality, we’ll have multiple Cognito user pool groups. The logic works similarly. We assign a role to each user group, set their precedence, and the ID token will contain the role’s name the user is eligible to use.

7. Summary

Authenticated users can receive AWS credentials and access resources directly from applications. To control access, we can create groups in the Cognito user pool and assign different IAM roles to each group. The ID token will contain the preferred role name.

We can refer to the ID token’s preferred_role claim in the Identity pool, which will return the relevant credentials to the application via STS.

8. References and further learning

Tutorial: Creating a user pool - How to create a user pool

Adding groups to a user pool - Straighforward title from AWS

Tutorial: Creating an identity pool - How to create an identity pool

Getting started with DynamoDB - Basic DynamoDB user guide

Fine-grained Access Control with Amazon Cognito Identity Pools - Great 20-minute video on access control with Identity pools