Controlling access to API endpoints with Cognito groups, Part 2

Adding authenticated users to Cognito groups in User pools is an easy way to assign AWS credentials. We can configure the roles' permissions policy to prevent non-elevated users from accessing privileged endpoints in the API Gateway.

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