shou2017.com
JP

Controlling ApiGateway access with a Lambda authorizer

Fri May 31, 2024
Sat Aug 10, 2024
AWS

Introduction

To control access to ApiGateway, you can use Cognito or a Lambda authorizer. The official documentation covers this, but here’s a simple diagram for understanding:

Controlling ApiGateway access

ApiGateway access control is used, for example, when you want only logged-in users to be able to call the API. The simplest way to implement this is to use Cognito.

You can implement it just by specifying the Cognito UserPool you are using. If you are using AWS CDK, you can do it like this:

// For CDK (Typescript)
const userPool = new cognito.UserPool(this, 'UserPool');

const auth = new apigateway.CognitoUserPoolsAuthorizer(this, 'booksAuthorizer', {
  cognitoUserPools: [userPool]
});

declare const books: apigateway.Resource;
books.addMethod('GET', new apigateway.HttpIntegration('http://amazon.com'), {
  authorizer: auth,
  authorizationType: apigateway.AuthorizationType.COGNITO,
});

When Cognito authentication succeeds, a token is returned to the frontend, and you can use that token to access ApiGateway. However, there is a problem with this method: you cannot set the HttpOnly attribute on the cookie.

Depending on your requirements, you may want to set the HttpOnly attribute on the cookie. If you want to set something custom in the cookie or elsewhere, AuthorizationType.COGNITO is not flexible enough. In that case, you can use a Lambda authorizer.

Lambda Authorizer

A Lambda authorizer means using a Lambda function to control access. This allows for very flexible access control. This time, I’ll show how to use Cognito for authentication and a Lambda authorizer for access control.

Connecting API Gateway and Lambda

The requirement this time is to call an API after authentication. First, connect API Gateway and Lambda.

The Lambda needs permission to call API Gateway. Also, since this time the token is set in a cookie, specify method.request.header.Cookie as the identitySource.

Since the cookie is set with the httpOnly attribute, the frontend cannot retrieve the token from the cookie, so you need to set the cookie in the header for API Gateway. In the API Gateway’s requestParameters, set "integration.request.header.Cookie": "method.request.header.Cookie",.

With this, the associated Lambda authorizer will receive the cookie, so the Lambda function can perform authentication and access control.

// For CDK (Typescript)
// Create a role for the lambdaAuthorizer
const lambdaAuthorizerRole = new Role(this, "LambdaAuthorizerRole", {
  roleName: LambdaAuthorizerRole,
  assumedBy: new ServicePrincipal("apigateway.amazonaws.com"),
  description: "Lambda Authorizer Role",
});
// Set up the lambdaAuthorizer
const lambdaAuthorizer = new TokenAuthorizer(this, "LambdaAuthorizer", {
  handler: authorizerFunc, // Lambda function
  identitySource: "method.request.header.Cookie",
  assumeRole: lambdaAuthorizerRole,
});

// By specifying the lambdaAuthorizer like this, you can control access to API Gateway
ApiEndPoint.addMethod(
  "POST",
  new LambdaIntegration(Lambda),
  {
    authorizer: lambdaAuthorizer,
    authorizationType: AuthorizationType.CUSTOM,
  }
);

Now API Gateway and Lambda are linked. Next, create the Lambda function.

Lambda Function

The Lambda function can be implemented as follows. Here, GetUserCommand is used to get user information.

The params for GetUserCommand require an AccessToken. If you simply pass the AccessToken to GetUserCommand without proper validation, any AccessToken issued elsewhere could pass authentication, which is a security risk.

Therefore, you need to properly validate the AccessToken. You can use the well-known jsonwebtoken, but here we use aws-jwt-verify provided by AWSLabs.

import {
  CognitoIdentityProvider,
  GetUserCommand,
  GetUserCommandInput,
  GetUserCommandOutput,
} from "@aws-sdk/client-cognito-identity-provider";
import { CognitoJwtVerifier } from "aws-jwt-verify";
import { apiGatewayTokenAuthorizerEvent } from "aws-lambda";

async function handler(event: apiGatewayTokenAuthorizerEvent) {
  // Get the cookies
  const cookies = event.authorizationToken || "";
  const userPoolId = process.env.USER_POOL_ID;
  const clientId = process.env.CLIENT_ID;
  const client = new CognitoIdentityProvider({});
  // Get the accessToken
  const accessToken = cookies
    .split(";")
    .find((row) => row.trim().startsWith("accessToken"))
    ?.split("=")[1];
  // If the value could not be obtained
  if (!accessToken || !userPoolId || !clientId) {
    return {
      principalId: "user",
      policyDocument: {
        Version: "2012-10-17",
        Statement: [
          {
            Action: "execute-api:Invoke",
            Effect: "Deny",
            Resource: "*",
          },
        ],
      },
    };
  }
  try {
    // Validate the accessToken
    const verify = CognitoJwtVerifier.create({
      userPoolId: userPoolId,
      tokenUse: "access",
      clientId: clientId,
    });
    const payload = await verify.verify(accessToken);
    console.log("Token is valid. Payload:", payload);
    // Get user information
    const params: GetUserCommandInput = {
      AccessToken: accessToken,
    };
    const command = new GetUserCommand(params);
    const response: GetUserCommandOutput = await client.send(command);
    const emailAttribute = response.UserAttributes?.find(
      ({ Name }) => Name === "email",
    );
    const email = emailAttribute?.Value;
    return {
      principalId: "user",
      policyDocument: {
        Version: "2012-10-17",
        Statement: [
          {
            Action: "execute-api:Invoke",
            Effect: "Allow",
            Resource: "*",
          },
        ],
      },
      context: {
        email: email,
      },
    };
  } catch (error) {
    console.error("Error:", error);
    return {
      principalId: "user",
      policyDocument: {
        Version: "2012-10-17",
        Statement: [
          {
            Action: "execute-api:Invoke",
            Effect: "Deny",
            Resource: "*",
          },
        ],
      },
    };
  }
}

export { handler };

Pitfalls

AccessToken Validation and Policy Settings

In this case, the Lambda authorizer uses Cognito’s GetUserCommand, but the Lambda authorizer does not have permission for GetUserCommand. However, GetUserCommand works fine.

This is because GetUserCommand only retrieves the user from the AccessToken parameter and does not require any special UserPool or authentication information. At first, I wondered why it worked and checked various IAM role settings.

The point is, you should properly validate the AccessToken.

The Resource for execute-api:Invoke

When authentication passes in the Lambda authorizer, the following is returned, but Resource is set to *.

return {
  principalId: "user",
  policyDocument: {
    Version: "2012-10-17",
    Statement: [
      {
        Action: "execute-api:Invoke",
        Effect: "Allow",
        Resource: "*",
      },
    ],
  },
  context: {
    email: email,
  },
};

At first, I thought I needed to specify the API Gateway ARN for Resource, so I tried specifying the API Gateway ARN in accordance with best practices for minimal policy, but even when I specified an incorrect API Gateway ARN for testing, it still worked.

I wondered if this was a security issue, so I looked into it and found the reason.

When you link a Lambda authorizer and API Gateway, you specify the API, so even if you specify an incorrect API Gateway ARN in Resource, it still works.

ApiEndPoint.addMethod(
  "POST",
  new LambdaIntegration(Lambda),
  {
    authorizer: lambdaAuthorizer,
    authorizationType: AuthorizationType.CUSTOM,
  }
);

If you think about it, with this code, the authorizer is only applied to the specified API, so specifying * for Resource is not a problem.

See Also