Controlling access to resources with Cognito groups and IAM roles
Arpad Toth
Posted on August 24, 2023
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.
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.
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.
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
Posted on August 24, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.