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;
- 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 andResource
which has to beARN
of the backing resource. In this tutorial we are usingmethodArn
.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 return401 - Unauthorized
right away. In this example, we are usingauthorization
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.
Comments powered by Disqus.