Controlling access to resources with Cognito groups and IAM roles
Tags: aws, security, serverless
Published: 2023-08-24 07:54:08

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 <a href="https://aws.amazon.com/iam/" class="link" target="_blank" rel="noopener noreferrer">IAM</a> 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 <a href="https://dev.to/aws-builders/using-cognito-groups-to-control-access-to-api-endpoints-346g" class="link" target="_blank" rel="noopener noreferrer">discussed a way</a> we can use 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> 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. <a href="https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-identity.html" class="link" target="_blank" rel="noopener noreferrer">Identity pools</a> will do a large chunk of the job, but tools like <a href="https://aws.amazon.com/developer/tools/" class="link" target="_blank" rel="noopener noreferrer">AWS SDK</a> 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 <a href="https://aws.amazon.com/dynamodb/" class="link" target="_blank" rel="noopener noreferrer">DynamoDB</a>.

![User pools and Identity pools](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/9kcuvf5mjvol0fgp3l0g.png)

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fk07vvwvb0ceegp0e8j6.png)

`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 <a href="https://docs.aws.amazon.com/STS/latest/APIReference/welcome.html" class="link" target="_blank" rel="noopener noreferrer">Security Token Service (STS)</a>. 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 <a href="https://jwt.io/" class="link" target="_blank" rel="noopener noreferrer">JSON Web Token (JWT)</a>.

The relevant part of the token is the following:

```jsonc
{
  // ...
  "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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7s2l4oaxmy30ftmewv9j.png)

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:

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

We should call the <a href="https://docs.aws.amazon.com/general/latest/gr/cognito_identity.html" class="link" target="_blank" rel="noopener noreferrer">**Identity pools endpoint**</a> 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 <a href="https://docs.aws.amazon.com/cognitoidentity/latest/APIReference/API_GetId.html#CognitoIdentity-GetId-request-Logins" class="link" target="_blank" rel="noopener noreferrer">GetID API reference page</a> will specify what keys we must define in each case.

We must also specify some headers in the request:

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

By default <a href="https://www.postman.com/" class="link" target="_blank" rel="noopener noreferrer">Postman</a> 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:

```bash
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:

<ul class="list">
  <li class="list__item">The endpoint is incorrect, i.e., we use User pools regional endpoints instead of Identity pools ones.</li>
  <li class="list__item">One or both headers are missing or their values are incorrect.</li>
</ul>

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

```json
{
    "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:

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

The response should contain the credentials:

```json
{
  "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:

```bash
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**:

```jsonc
// 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 <a href="https://react.dev/" class="link" target="_blank" rel="noopener noreferrer">React</a> and <a href="https://aws.amazon.com/amplify/" class="link" target="_blank" rel="noopener noreferrer">Amplify</a>, we can write a very simple component like this:

```jsx
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:

```bash
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

<a href="https://docs.aws.amazon.com/cognito/latest/developerguide/tutorial-create-user-pool.html" class="link" target="_blank" rel="noopener noreferrer">Tutorial: Creating a user pool</a> - How to create a user pool

<a href="https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-user-groups.html" class="link" target="_blank" rel="noopener noreferrer">Adding groups to a user pool</a> - Straighforward title from AWS

<a href="https://docs.aws.amazon.com/cognito/latest/developerguide/tutorial-create-identity-pool.html" class="link" target="_blank" rel="noopener noreferrer">Tutorial: Creating an identity pool
</a> - How to create an identity pool

<a href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/GettingStartedDynamoDB.html" class="link" target="_blank" rel="noopener noreferrer">Getting started with DynamoDB</a> - Basic DynamoDB user guide

<a href="https://www.youtube.com/watch?v=tAUmz94O2Qo" class="link" target="_blank" rel="noopener noreferrer">Fine-grained Access Control with Amazon Cognito Identity Pools</a> - Great 20-minute video on access control with Identity pools