Controlling access in service-to-service communications with Cognito - Part 2

We can quickly set up token validation in API Gateway using a Cognito User Pool authorizer. API Gateway natively integrates with Cognito, and we don't need to create any custom authorizer logic to control access to the endpoints.

This is the second (and last) part of the secure service-to-service communication with Cognito mini-series. If you haven’t read Part 1, consider skimming through the post before continuing with this content.

1. The scenario - refresher

Bob has looked for a solution for authorizing requests between the microservices he and his team are developing. He decided to use Amazon Cognito and the client_credentials grant type. This grant is specifically for controlling access in service-to-service communications and follows the OAuth 2.0 standards.

Last week Bob managed to set up everything Cognito, and he was able to get the access token in Postman.

He will now create a Cognito User Pool authorizer in API Gateway. Bob will then add it to the endpoint he wants to protect, and will define the required permission (scope) for the token validation.

2. API Gateway settings

For simplicity, let’s assume that Bob only has one endpoint in the API. After he has created more endpoints, he can follow the same steps to integrate API Gateway with Cognito for each endpoint. He could even use different authorization methods (for example, Lambda authorizers) for the each endpoint.

2.1. Creating the authorizer

First, we need to create an authorizer in API Gateway. Let’s call it s2s-authorizer.

Creating the Cognito authorizer
Creating the Cognito authorizer

We have to select Cognito for Type and specify the user pool.

2.2. Protecting the /files endpoint

Next, we should go to the Method Request on the GET /files endpoint.

In the Authorization section, select the name of the Cognito authorizer (s2s-authorizer).

Selecting the authorizer
Selecting the authorizer

After clicking the little tick icon, the API Gateway UI will automatically offer the OAuth Scopes section. We must specify the permission that API Gateway will look for in the access token. In this case, let the scope be demo/read.file.

Adding the scope to the endpoint
Adding the scope to the endpoint

Our token will contain this scope (and demo/write.file too), so the request to the endpoint should be successful.

2.3. Testing the endpoint in Postman

We can now test the endpoint in Postman.

In the Authorization tab, we select Bearer Token and paste the access token we received from Cognito. Don’t write Bearer in the box because Postman will convert it to Bearer Bearer <TOKEN>, and it won’t work.

If we get the Invoke URL from Stages in API Gateway and call the <INVOKE_URL>/files endpoint with the access token in the header, we should get a successful response from the API.

3. axios call

Obviously, Bob won’t use Postman to get the token and invoke the API. Instead, one application will call the other service, so he will have to implement the same logic in a Lambda function.

The first application, which consists of the Lambda function, will invoke the URL of the API Gateway of the second service. For simplicity, we can create another Lambda function behind the endpoint. The endpoint handler can return some fake data. In reality, the integration will probably fetch some data from a database or perform business logic.

The caller service will use axios to make the request.

3.1. Fetching the token

We must convert the steps from Part 1 to get the access token from Cognito.

This part of the code can look like this:

interface CognitoClientCredentials {
  access_token: string;
  expires_in: number;
  token_type: string;
}

const getCredentials = async (): Promise<CognitoClientCredentials> => {
  const authUrl = `${COGNITO_DOMAIN}/oauth2/token`;
  const authRequestBody = new URLSearchParams({
    grant_type: "client_credentials",
  });

  const authParams = {
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      Authorization: `Basic ${Buffer.from(
        `${APP_CLIENT_ID}:${APP_CLIENT_SECRET}`
      ).toString("base64")}`,
    },
  };

  const { data } = await axios.post<CognitoClientCredentials>(
    authUrl,
    authRequestBody,
    authParams
  );
  console.log("Successfully received access token");
  return data;
};

The getCredentials function fetches the token from Cognito. COGNITO_DOMAIN, APP_CLIENT_ID, and APP_CLIENT_SECRET are environment variables in the Lambda function, which will call the second service.

Content-Type must be application/x-www-form-urlencoded, so we must stringify the request body that contains the grant type.

3.2 Calling the other service

Now that we have the access token, we will place it in the Authorization header of the second request.

The code can be the following:

export const handler = async () => {
  try {
    const { access_token: accessToken } = await getCredentials();

    const { data: files } = await axios.get(FILES_SERVICE_URL, {
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    });

    console.log("Successfully received data: ", files);
    return files;
  } catch (error) {
    console.log("An error occurred", error);

    throw error;
  }
};

As we mentioned earlier, the access token, which contains the required scope (demo/read.file) API Gateway needs, will be in the Authorization header.

The request should succeed, and we can process the response.

4. Summary

API Gateway integrates with Cognito and validates access tokens based on their scopes. We can quickly set up a proof-of-concept for an internal service-to-service authorization pattern.

The key steps are to use grant_type: client_credentials to fetch the access token from Cognito and set up the required scope for the Cognito authorizer in API Gateway.

5. Further reading

Github repo - The stack in CDK

Control access to a REST API using Amazon Cognito user pools as authorizer - More info on the topic in the documentation