Home API Gateway Lambda Custom Authorizer with AWS CDK
Post
Cancel

API Gateway Lambda Custom Authorizer with AWS CDK

In this tutorial we will learn how to build and attach a Lambda Custom Authorizer for our Lambda Rest Api by provisioning required resources with AWS CDK. Though, before moving forward lets talk about what is it and when we need to use it. A Lambda authorizer is a Lambda function to authenticate incoming requests before hitting our integration resources. It can also be used for both authentication and authorization as well. In my previous tutorials, we already saw how the authentication step is being handled by AWS Cognito UserPool authorizers to validate incoming JWTs where the request is either rejected or accepted by API Gateway. There are common use cases like; checking if the client has sufficient role(s) to access to the requested resources, injecting client’s identity context (for example; getting user related records from DB) into request context before forwarding the request to backing Lambda functions.

Lambda authorizers are generally preferred for distributed microservice architectures which contains many backing resources so that authorization and authentication logic can reside in one central place. Alternatively, you can write your own library and use it in each of your lambda functions but an update on the library means you have to re-deploy all of them.

The Lambda authorizer flow is pretty straightforward;

lambda_authorizer_flow

  • Client makes a call to the protected resource
  • API Gateway intercepts the request and forwards it to the attached Lambda Authorizer
  • Lambda Authorizer authenticates the request and generates a basic IAM policy including ALLOW/DENY effect
  • If policy has an ALLOW effect then request will be forwarded to the backing resource, otherwise a 403 response will be returned back to client.
  • (OPTIONAL) API Gateway caches the policies generated by Lambda Authorizer meaning if client’s identity belongs to a cached entry then API Gateway will act on cached result.

Lambda Authorizer Types

There are two types of custom authorizers; TOKEN and REQUEST.

  • TOKEN based Lambda authorizer contains only the client’s identity information within a token such as JWT or an OAuth token.
  • REQUEST based Lambda authorizer has more context including query string or path parameters, headers, stage variables etc

To be more specific lets check the event structure of both types.

TOKEN based event payload contains only 3 properties; type, methodArn, and authorizationToken which is the JWT access token we are passing within authorization header.

1
2
3
4
5
{
  "type": "TOKEN",
  "methodArn": "arn:aws:execute-api:us-east-1:1234567890:cl2u9uoz65/prod/GET/awesomeapi",
  "authorizationToken": "eyJraWQiOiJBejFPRlBM..."
}


REQUEST based event payload has more detailed information compared to the TOKEN based authorizer.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
{
  "type": "REQUEST",
  "methodArn": "arn:aws:execute-api:us-east-1:1234567890:cl2u9uoz65/prod/GET/awesomeapi",
  "resource": "/awesomeapi",
  "path": "/awesomeapi",
  "httpMethod": "GET",
  "headers": {
    "accept": "*/*",
    "authorization": "eyJraWQiOiJNMEFY...",
    "Host": "xhtz5czpv3.execute-api.us-east-1.amazonaws.com",
    "user-agent": "curl/7.64.1",
    "X-Amzn-Trace-Id": "Root=1-6414d349-75b74c1a609137f6530b38fd",
    "X-Forwarded-For": "176.234.133.118",
    "X-Forwarded-Port": "443",
    "X-Forwarded-Proto": "https"
  },
  "multiValueHeaders": {
    "accept": [
      "*/*"
    ],
    "authorization": [
      "eyJraWQiOiJNMEFY..."
    ],
    "Host": [
      "xhtz5czpv3.execute-api.us-east-1.amazonaws.com"
    ],
    "user-agent": [
      "curl/7.64.1"
    ],
    "X-Amzn-Trace-Id": [
      "Root=1-6414d349-75b74c1a609137f6530b38fd"
    ],
    "X-Forwarded-For": [
      "176.234.133.118"
    ],
    "X-Forwarded-Port": [
      "443"
    ],
    "X-Forwarded-Proto": [
      "https"
    ]
  },
  "queryStringParameters": {},
  "multiValueQueryStringParameters": {},
  "pathParameters": {},
  "stageVariables": {},
  "requestContext": {
    "resourceId": "r3nj8r",
    "resourcePath": "/awesomeapi",
    "httpMethod": "GET",
    "extendedRequestId": "B8XzfGKvoAMF9Rg=",
    "requestTime": "17/Mar/2023:20:53:29 +0000",
    "path": "/prod/awesomeapi",
    "accountId": "1234567890",
    "protocol": "HTTP/1.1",
    "stage": "prod",
    "domainPrefix": "xhtz5czpv3",
    "requestTimeEpoch": 1679086409237,
    "requestId": "76b21148-c1e6-4d8d-96c8-b35cca333ee1",
    "identity": {
      "cognitoIdentityPoolId": null,
      "accountId": null,
      "cognitoIdentityId": null,
      "caller": null,
      "sourceIp": "176.234.133.118",
      "principalOrgId": null,
      "accessKey": null,
      "cognitoAuthenticationType": null,
      "cognitoAuthenticationProvider": null,
      "userArn": null,
      "userAgent": "curl/7.64.1",
      "user": null
    },
    "domainName": "xhtz5czpv3.execute-api.us-east-1.amazonaws.com",
    "apiId": "cl2u9uoz65"
  }
}

Keep in mind that, type and methodArn properties exist in both event payload types and methodArn is actually being used for caching authorizer responses. Here can be found a more detailed documentation from AWS.

Lambda Authorizer Responses

Lambda authorizers are configured to return an IAM policy with an Allow|Deny effect to either forward the request to the backing resources or rejecting it. There is also an exception where we can throw an error with a message Unauthorized which will be resolved to a 401 - Unauthorized from API Gateway side. On the other hand, an IAM policy with a Deny effect will return 403 - Forbidden and any other error will be resolved to 500 - Internal Server Error. Here is what a common output response looks like;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
  "principalId": "2p7v37skislq00nffruv0gs378",
  "policyDocument": {
    "Version": "2012-10-17",
    "Statement": [{
      "Action": "execute-api:Invoke",
      "Effect": "Allow",
      "Resource": "arn:aws:execute-api:us-east-1:1234567890:cl2u9uoz65/prod/GET/awesomeapi"
    }]
  },
  "context": {
    "userId": 123,
    "companyId": 456,
    "role": "ADMIN"
  }
}


  • principalId: This can be anything unique to the client like an email address or sub attribute from a JWT.
  • policyDocument: This is the basic IAM policy which contains Allow/Deny as Effect, execute-api:Invoke as Action and Resource which has to be ARN of the backing resource. In this tutorial we are using methodArn.
  • context: A custom key-value map to contain and pass forward information to the backing resources. This is optional.
  • usageIdentifierKey: API key of the backing API Lambdas if they are configured for any usage plan. It is not used in this example since we didn’t configure any usage plans for our Lambda.

There is an important note on caching. If we revoke the credentials of the cached client, API Gateway won’t invalidate the related cached entry within the duration of the cache. This can be tested by deploying the stack, getting a token and calling the protected resource with that token which will cache the token for the duration defined for the authorizer. Later on, we can remove the Cognito App Client from AWS console and make another call with the same token to the resource and verify that request won’t get rejected.

Lambda Authorizer Function

We are using a REQUEST type Lambda authorizer function and getting authorization (this header name will be passed into identitySource list of the authorizer definition in our CDK code) header to validate. This function uses CognitoJwtVerifier library which is owned by AWS. This library already encapsulates the logic for authenticating the token. A few years back this library wasn’t there, thus, forcing us to handle the internals of validation of the token which contains steps for downloading the JWKS keys and verifying the signature of the token. Here is a list of well known libraries sorted for different languages.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import { PolicyDocument } from 'aws-lambda';
import { CognitoJwtVerifier } from 'aws-jwt-verify';
import { APIGatewayAuthorizerResult } from 'aws-lambda/trigger/api-gateway-authorizer';
import 'source-map-support/register';

const cognitoJwtVerifier = CognitoJwtVerifier.create({
  userPoolId: process.env.USERPOOL_ID || '',
  clientId: process.env.CLIENT_ID,
  tokenUse: 'access',
});

export const handler = async function (event: any): Promise<APIGatewayAuthorizerResult> {
  console.log(`event => ${JSON.stringify(event)}`);

  // authentication step by getting and validating JWT token
  const authToken = event.headers['authorization'] || '';
  try {
    // @ts-ignore
    const decodedJWT = await cognitoJwtVerifier.verify(authToken);

    // After the token is verified we can do Authorization check here if needed.
    // If the request doesn't meet authorization conditions then we should return a Deny policy.
    const policyDocument: PolicyDocument = {
      Version: '2012-10-17',
      Statement: [
        {
          Action: 'execute-api:Invoke',
          Effect: 'Allow', // return Deny if you want to reject the request
          Resource: event['methodArn'],
        },
      ],
    };

    // This is the place you inject custom data into request context which will be available
    // inside `event.requestContext.authorizer` in API Lambdas.
    const context = {
      'userId': 123,
      'companyId': 456,
      'role': 'ADMIN',
    };

    const response: APIGatewayAuthorizerResult = {
      principalId: decodedJWT.sub,
      policyDocument,
      context,
    };
    console.log(`response => ${JSON.stringify(response)}`);

    return response;
  } catch (err) {
    console.error('Invalid auth token. err => ', err);
    throw new Error('Unauthorized');
  }
};


CDK Code

The structure of the cdk code base is same with my previous authorization code flow except the ApiGatewayStack will have 2 lambda function definitions; one for the authorizer lambda and other one is for the API Lambda (we used a mock integration lambda previously).

API Gateway Stack

As mentioned above, there are two lambda function definitions which the authorizer lambda is configured as REQUEST type Lambda by using RequestAuthorizer construct for the integration API Lambda. For a TOKEN type Lambda we can switch to TokenAuthorizer construct.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
import * as cdk from 'aws-cdk-lib';
import * as apigw from 'aws-cdk-lib/aws-apigateway';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as lambdaNodejs from 'aws-cdk-lib/aws-lambda-nodejs';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import { Construct } from 'constructs';

export class ApiGatewayStack extends cdk.Stack {
  constructor(scope: Construct,
              id: string,
              cognitoUserPool: cognito.IUserPool,
              cognitoUserPoolAppClient: cognito.IUserPoolClient,
              props?: cdk.StackProps) {
    super(scope, id, props);

    const authLambda = new lambdaNodejs.NodejsFunction(this, 'auth-lambda', {
      entry: './src/custom-auth-lambda.ts',
      handler: 'handler',
      runtime: lambda.Runtime.NODEJS_18_X,
      environment: {
        USERPOOL_ID: cognitoUserPool.userPoolId,
        CLIENT_ID: cognitoUserPoolAppClient.userPoolClientId,
        NODE_OPTIONS: '--enable-source-maps',
      },
    });

    const apiLambda = new lambdaNodejs.NodejsFunction(this, 'awesome-api-lambda', {
      entry: './src/api-lambda.ts',
      handler: 'handler',
      runtime: lambda.Runtime.NODEJS_18_X,
      environment: {
        NODE_OPTIONS: '--enable-source-maps',
      },
    });

    const awesomeApi = new apigw.RestApi(this, 'awesome-api', {
      endpointTypes: [apigw.EndpointType.REGIONAL],
      deploy: true,
      deployOptions: {
        stageName: 'prod',
      },
      defaultCorsPreflightOptions: {
        allowOrigins: apigw.Cors.ALL_ORIGINS,
        allowMethods: apigw.Cors.ALL_METHODS,
      },
    });

    // Lambda Authorizer with 'TOKEN' type
    // const authorizer = new apigw.TokenAuthorizer(this, 'awesome-api-authorizer', {
    //   handler: authLambda,
    //   identitySource: apigw.IdentitySource.header('authorization'),
    //   resultsCacheTtl: cdk.Duration.seconds(0),
    // });


    // Lambda Authorizer with 'REQUEST' type
    const authorizer = new apigw.RequestAuthorizer(this, 'awesome-api-request-authorizer', {
      handler: authLambda,
      identitySources: [apigw.IdentitySource.header('authorization')],
      resultsCacheTtl: cdk.Duration.seconds(0),
    });

    // PATH => /awesomeapi
    const awesomeApiResource = awesomeApi.root.addResource('awesomeapi');
    awesomeApiResource.addMethod(
      'GET',
      new apigw.LambdaIntegration(apiLambda),
      {
        methodResponses: [{
          statusCode: '200',
          responseParameters: {
            'method.response.header.Content-Type': true,
            'method.response.header.Access-Control-Allow-Origin': true,
          },
        }],
        authorizer: authorizer,
        authorizationType: apigw.AuthorizationType.CUSTOM,
      },
    );
  }
}


  • handler: The IFunction property which we pass the authorizer Lambda reference.
  • identitySources: A list of string parameters to make request authorizer using as the caching key. Supported parameters are headers, query parameters, stage variables and context. If request itself doesn’t contain any defined identity source then authorizer will return 401 - Unauthorized right away. In this example, we are using authorization header.
  • resultsCacheTtl: Duration to determine caching. We should set to 0 to disable it.


Testing

Getting an Access Token

As the first step we will get an access token by making a call to /token endpoint.

1
2
3
4
> curl --request POST 'https://buraktas-awesome-domain.auth.us-east-1.amazoncognito.com/oauth2/token' \
--header 'Authorization: Basic MnA3djM3c2tpc2xxMDBuZmZydXYwZ3MzNzg6MXQxbnZuNDA2a2dna3J1cDd2MmFvaGJrMG83bzZza2ExNmg3Z3JrMjhrMGJxZWlsa3RwaQ==' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=client_credentials'
1
2
3
4
5
{
  "access_token": "eyJraWQiOiJB...",
  "expires_in": 3600,
  "token_type": "Bearer"
}


Calling Protected Endpoint

We will call the protected endpoint with the provided access token.

1
2
3
4
5
6
7
8
9
10
11
12
> curl -i 'https://cl2u9uoz65.execute-api.us-east-1.amazonaws.com/prod/awesomeapi' \
--header 'Authorization: eyJraWQiOiJBe...'

HTTP/2 200
date: Sun, 19 Mar 2023 16:13:07 GMT
content-type: application/json
content-length: 29
x-amzn-requestid: 144ef0b2-03a3-401b-9b34-4f4e8ac70e24
x-amz-apigw-id: CCUm9GxKIAMFy7A=
x-amzn-trace-id: Root=1-64173492-23d1c50c16d23768752fc33a;Sampled=0

Hello from protected resource


Synth Stack(s)

1
2
3
> cdk synth

Supply a stack id (LambdaAuthorizerMainStack, LambdaAuthorizerMainStack/CognitoStack, LambdaAuthorizerMainStack/ApiGatewayStack) to display its template.


Deploy Stack(s)

1
> cdk deploy --all


Destroy Stack(s)

Don’t forget to delete the stack after your testing.

1
> cdk destroy --all


I hope this tutorial helped you to understand how Lambda Authorizers work and how we can handle the authentication and authorization steps within it. Here you can find the related github repository. I also want to give a special thanks for Alex De Brie for a very great post about Lambda Authorizers which helped me to understand about them a few years ago.

This post is licensed under CC BY 4.0 by the author.

OAuth 2.0 Client Credentials Flow with AWS Cognito in AWS CDK

API Gateway Add Base Path Mapping into Existing Custom Domain with AWS CDK

Comments powered by Disqus.