Controlling access to API endpoints with Cognito groups, Part 2
1. The scenario
Say we have an internal application where users belong to different permission groups. Some can only read data, and others might write them, too.
I discussed a way to control access to endpoints using JSON web tokens and a Lambda authorizer earlier.
In this post, I’ll aim to eliminate the tokens and the authorizer. The goal is to write less code and delegate more responsibility to AWS services!
2. Solution overview
The post shows a simplified solution, but we can also apply the principles on a larger scale. We will have two users, Alice and Bob, and two endpoints, GET /test
and POST /test
.
We’ll store the application user data in a Cognito user pool and create two groups there: FullAccess
and ReadOnlyAccess
.
Alice will belong to the FullAccess
group, meaning she can both read and write data. Bob is in ReadOnlyAccess
, so he can only invoke GET
endpoints but cannot access anything else.
Each group will have an IAM role assigned. The roles will allow read/write and read access to the members of the FullAccess
and ReadOnlyAccess
groups, respectively.
After the user has signed in, the application will call some endpoints in an identity pool. You can read more on how this process works in this post. The identity pool then assumes the IAM role assigned to the Cognito group the corresponding user is in. The application code will then access AWS resources with the role’s credentials.
In this scenario we protect the backend compute resources with an HTTP API type of API Gateway. We’ll set up IAM authorization at each route, which eliminates the need for tokens and custom authorizers. It’s also the safest way to protect an endpoint since it delegates the authorization task to the robust IAM service.
3. Pre-requisites
This post won’t explain how to create
- an HTTP API
- integrations for API Gateway
- a Cognito identity pool
- 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.
4. Key steps
Let’s see what the key steps are to implement the architecture.
4.1 Setting up the IAM permissions
We’ll need two separate roles, one for the FullAccess
group and another for ReadOnlyAccess
.
The mutual setting of the two roles is the trust policy. In both cases, we must allow the identity pool to assume the role:
{
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "cognito-identity.amazonaws.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"cognito-identity.amazonaws.com:aud": "IDENTITY_POOL_ID"
}
}
}
]
}
However, the permissions policies will differ because users in different groups will not have the same access.
The FullAccess
policy can look like this:
{
"Statement": [
{
"Effect": "Allow",
"Action": "execute-api:Invoke",
"Resource": [
"arn:aws:execute-api:eu-central-1:ACCOUNT_ID:API_ID/*/GET/test",
"arn:aws:execute-api:eu-central-1:ACCOUNT_ID:API_ID/*/POST/test"
]
}
]
}
This statement allows the identity to invoke both the GET /test
and POST /test
endpoints.
The ReadOnlyAccess
group’s permissions policy won’t contain the statement about the POST
endpoint because those users only have read access:
{
"Statement": [
{
"Effect": "Allow",
"Action": "execute-api:Invoke",
"Resource": [
"arn:aws:execute-api:eu-central-1:ACCOUNT_ID:API_ID/*/GET/test",
]
}
]
}
We call the roles TestApiFullAccess
and TestApiReadAccess
.
4.2. Enabling the IAM authorizer
We can protect both REST and HTTP API endpoints with IAM. It’s a great way to perform authorization in machine-to-machine communication (e.g., microservices), but we can also use it between the front end and the back end.
When we want to enable IAM authorization in HTTP APIs, we go to Authorization, select the route and method (e.g., GET
), and attach the IAM (built-in)
authorizer to the route. We can also do it programmatically using the CLI.
4.3 Signing the request
When we call an AWS service API endpoint (e.g., API Gateway Invoke
, SNS Publish
or S3 CreateBucket
), we must sign the request using Signature Version 4. The SDKs and the CLI will automatically do it on behalf of us using the credentials we provide. But when we protect a public API endpoint with IAM, we should build the logic to sign the request in the code.
We can use the Signer
class Amplify provides in a React front-end application to sign the API requests whose targets are various the API Gateway endpoints.
// signing the request
const roleCredentials = {
access_key: credentials.accessKeyId,
secret_key: credentials.secretAccessKey,
session_token: credentials.sessionToken,
};
const service = {
service: 'execute-api',
region: 'eu-central-1',
};
async function callGetEndpoint() {
const request = {
method: 'GET',
url: 'https://DOMAIN_NAME/test',
};
const signedRequest = Signer.sign(request, roleCredentials, service);
try {
const response = await axios(signedRequest);
setApiResponse(response.data);
} catch (error) {
setApiResponse({ message: 'Not allowed!!' });
}
}
The sign
method (as the name suggests) will sign the request. It accepts three arguments: the request parameters, the IAM credentials, and the service configuration.
The request
object will contain the url
and method
properties. We should add a stringified data
object for POST
requests where we have some payload in the request body.
roleCredentials
contain the AWS credentials: access key ID, secret access key, and session token. These are available from the return value of the fromCognitoIdentityPool
call the @aws-sdk/credential-providers
package provides (see below).
The main point in the service configuration is that we must specify execute-api
as service
and the region.
The callPostEndpoint
function is very similar except for some minor differences in the endpoint, method, and the extra payload.
5. Putting it all together
Let’s put it together and see a simple working React code.
import axios from 'axios';
import React, { useEffect } from 'react';
import '@aws-amplify/ui-react/styles.css';
import { withAuthenticator } from '@aws-amplify/ui-react';
import { Signer } from 'aws-amplify';
import { fromCognitoIdentityPool } from '@aws-sdk/credential-providers';
function App({ signOut, user }) {
const [credentials, setCredentials] = React.useState({});
const [apiResponse, setApiResponse] = React.useState({});
// getting the ID token that contains the role's name and ARN
const idToken = user.signInUserSession.idToken.jwtToken;
// making the role's credentials available
useEffect(() => {
async function credentialsFromCognitoIdentityPool() {
const credentials = await fromCognitoIdentityPool({
clientConfig: { region: 'eu-central-1' },
identityPoolId: 'IDENTITY_POOL_ID',
logins: {
'cognito-idp.eu-central-1.amazonaws.com/USER_POOL_ID':
idToken,
},
})();
setCredentials(credentials);
}
credentialsFromCognitoIdentityPool();
}, []);
// Insert here the request signing code snippet
// for GET and POST endpoints from above
return (
<>
<button onClick={callGetEndpoint}>Call GET API endpoint</button>
<button onClick={callPostEndpoint}>Call POST API endpoint</button>
<p>{apiResponse.message}</p>
</>
);
}
export default withAuthenticator(App);
The front-end application will submit signed requests to the corresponding API Gateway endpoints. The idToken
will contain the role name and ARN that we have assigned to the user group in Cognito. By calling the fromCognitoIdentityPool
method, we’ll receive the credentials of the role assigned to the group the user is part of.
The signed request will include the credentials in the Authorization
header. The API Gateway will then use IAM to check the signature for permissions that allow to invoke the endpoint.
This way, we managed to eliminate the use of tokens and Lambda authorizers. We use Cognito User pools, Cognito Identity Pools, and IAM authorization to control access to API Gateway endpoints.
6. Considerations
The above solution is a possible way to control access to API Gateway endpoints, but it’s not the way or, at least, not the only way to do so. One should investigate their use case before applying any codes and principles above in production.
7. Summary
We can add users to groups in Cognito User pools that can have IAM roles assigned. The role’s permissions policy is suitable for controlling access to AWS services, like API Gateway.
This way, we can protect API endpoints with IAM authorization and control which users can access which endpoints.
8. Further reading
Working with HTTP APIs - Official documentation about HTTP API
Choosing between REST APIs and HTTP APIs - Comparison with lots of tables
Getting started with user pools - How to create a user pool with an app client
Adding groups to a user pool - Straighforward title from AWS
Tutorial: Creating an identity pool - How to create an identity pool