Type-Safe AWS Lambda Authorizer for ApiGateway V2 with TypeScript

Type-Safe AWS Lambda Authorizer for ApiGateway V2 with TypeScript
Photo by Mehmet Ali Peker / Unsplash

In this article, we'll explore a neat solution to define your types and ensure perfect type validation between your authorizer and service lambdas for apiGateway V2

Specification

The following is an implementation using:
- API Gateway v2 (HTTP API)
- Authorizer Lambda with payload v2
- Full AWS TypeScript

Why?

Why Do I Need Perfect TypeScript Integration Between Authorizer Lambda and Service Lambda?


Good TypeScript checks will prevent errors more effectively than any unit tests in this scenario. If you ever change one of your components—either the authorizer or service lambda—TypeScript will help you identify all affected services before you deploy and cause downtime. Unit testing in this situation will just hardcode your lambda with what you have at that time and not evolve to reflect the changes of different components during the development workflow over a longer period.

Why Should I Use Response Payload v2 vs V1?



It is cleaner, simpler, and suits my project needs better. More technical context on it can be found on aws official docs

Why Should I Use HTTP API Instead of API Gateway v1?

It is faster and cheaper. However, read its limitations before picking it; it’s more of a proxy endpoint than a full-fledged API with additional features for checks. Docs with comparison

Full GitHub with an example can be found [here](https://github.com/bogdan-largeanu/blog-demo-typescript-apigatewayv2-authorizer).

Step-by-Step Guide



Step 1: Create a Lambda

1. Define the Interface


We first create a lambda authorizer. Let's define the interface. I will explain `contextAuth2` in a second. A simple authorizer must adhere to the following format AWS Documentation

interface IResponseAuthSimplePayloadv2 {
  isAuthorized: boolean
  context: contextAuth2
}

interface for auth

2. Define the Context Object

`contextAuth2` is the object we want to pass from our authorization lambda to our service lambda. It can be anything; a common pattern is to pass the `userId`. We will save this in a separate file so we can reference it from both the authorizer and service lambda.

export interface contextAuth2 {
    userId: string;
};
  

the interface we pass between auth lambda and service lambda

3. Type Definition for Authorizer

For our authorizer, we define both the input and output response to ensure our type is recognized as an authorizer lambda with a simple v2 payload and only accepts our custom object defined in `contextAuth2`.

const authorizerHandler = async (
  authorizerEventV2: APIGatewayRequestAuthorizerEventV2,
): Promise<APIGatewaySimpleAuthorizerWithContextResult<contextAuth2>> => {}

4. Combine Everything for the Lambda Authorizer


Let's put everything together for our lambda authorizer.

import {
  APIGatewayRequestAuthorizerEventV2,
  APIGatewaySimpleAuthorizerWithContextResult,
} from 'aws-lambda';
import {contextAuth2 } from './IAuthorizerContext'

// format simple authorizer must be https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-lambda-authorizer.html
interface IResponseAuthSimplePayloadv2 {
  isAuthorized: boolean
  context: contextAuth2
}

const authorizerHandler = async (
  authorizerEventV2: APIGatewayRequestAuthorizerEventV2,
): Promise<APIGatewaySimpleAuthorizerWithContextResult<contextAuth2>> => {

// We set the template of the response. IsAuthorized is mandatory 
// while context is option and up to us to implement there whatever values we want to pass to our service lambda
  const response : IResponseAuthSimplePayloadv2= {
    isAuthorized: false,
    context: {
      userId: '',
      // if add another field here we will get an error 
    },
  };

  const authToken = authorizerEventV2.identitySource[0];
  // will assume the token is valid for the purpose of the demo, but here's where you implement your validation on the authToken
  if(authToken){
      response.isAuthorized = true
      response.context.userId = "userIdExtractedFromAuthToken"
            // if add another field here on context we will also get an error too since is not defined in the context interface 
            // example : response.context.unexpectedField = 1
  }

  return response;
};

module.exports = { authorizerHandler };

Step 2: Reuse the Interface in Our Service Lambda


1. Combine Expected Event Types and Custom Context

We build a type that combines both the expected event types and our custom context passed inside the lambda.

type CustomAuth = APIGatewayAuthorizerResultContext & contextAuth2

2. Pass the Custom Type into Handler Interface


We pass this into our handler interface to get all the lambda event types + authorizer v2 with simple response + our custom context.

const handler: APIGatewayProxyHandlerV2WithLambdaAuthorizer<CustomAuth> = async (
  event
) => { }

3. Assemble Everything in the Service Lambda

import {
  APIGatewayProxyHandlerV2WithLambdaAuthorizer,
  APIGatewayAuthorizerResultContext,
} from 'aws-lambda';
import { contextAuth2 } from "./IAuthorizerContext"

interface requestBody {
  file_no: string;
}
// We build a type that combines both the expected Event types and our custom context passed inside the lambda
type CustomAuth = APIGatewayAuthorizerResultContext & contextAuth2


const handler: APIGatewayProxyHandlerV2WithLambdaAuthorizer<CustomAuth> = async (
  event
) => {

  const extractUserIdFromAuth = () => {
    // if lambda is run locally authorization lambda won't run the provide the trintId
    if (process.env.IS_OFFLINE) return 'offline-lambda-trint-id';

    // This is not automatically typescript checked. If I tried to access a property on lambda. that dose not exist we will get a type error as we should
    // example : `event.requestContext.authorizer.lambda.banana`. This also mean if we change our interface in authorizer, any lambda importing this interface will throw a type error to warn us we need to refactor the code 
    const userId  = event.requestContext.authorizer.lambda.userId;
    return userId;
  };

  const unmarshelledBody: requestBody = JSON.parse(event.body || '');

  const userId = extractUserIdFromAuth();
  const {
    file_no: fileNo,
  } = unmarshelledBody;  

  return {
    isBase64Encoded: false,
    statusCode: 200,
    headers: { 'lambda-version': `1.1` },
    body: JSON.stringify({
      userIdFromAuthorizerLambda: userId, 
      fileNo
    }),
  };
};

module.exports = { handler };

Now any future changes to our authorizer, such as adding a new field or removing an existing one, will trigger TypeScript errors, helping us refactor the code accordingly.

This was done using serverless, but the same can be deployed with SAM or Terraform.

service: demo-typescript
package:
  individually: true
  excludeDevDependencies: true

plugins:
  - serverless-esbuild # must be before offline
  #  - serverless-prune-plugin
  - serverless-offline
  - serverless-iam-roles-per-function
custom:
  #Bundler
  esbuild:
    minify: false
    packager: yarn
    watch:
      pattern: ['.src/index.ts', 'src/**/*.ts']
      ignore: ['.serverless/**/*', '.build']
    config: './esbuild.config.js'

provider:
  name: aws
  runtime: nodejs14.x
  stage: ${opt:stage}
  region: ${opt:region}

 
  # Tags
  stackTags: &stackTags
    ServiceGroup: DemoTypescriptAuthv2
    AppId: DemoTypescriptAuthv2
  tags: *stackTags

  #  Logs and metrics
  logRetentionInDays: 365
  logs:
    httpApi: true #enable logging for apigateway v2
  httpApi:
    metrics: true #enables detailed metric
    useProviderTags: true
    #  Authorizer
    authorizers:
      CustomLambdaAuthorizer:
        type: request #  Should be set to 'request' for custom Lambda authorizers
        functionName: AuthorizerDemoTypescript
        resultTtlInSeconds: 300 # max is 1h = 3600. 
        enableSimpleResponses: true #         # Set if authorizer function will return authorization responses in simple format (default: false)
        payloadVersion: '2.0' #
        identitySource:
          - $request.header.Authorization
        # Optional. Applicable only when using externally defined authorizer functions
        # to prevent creation of permission resource. Set this to true if you use this authorizer in another serverless service
        managedExternally: false

functions:
  AuthorizerDemoTypescript:
    memorySize: 256
    timeout: 10
    handler: src/authorizer.authorizerHandler
  
  PresignedUrls: 
    handler: src/index.handler
    memorySize: 256
    timeout: 10

    events:
      - httpApi:
          method: POST
          path: /demo-typescript-authorizer
          authorizer: # Optional
                # Name of an authorizer defined in 'provider.httpApi.authorizers'
                name: CustomLambdaAuthorizer

Full GitHub with an example can be found here.

https://github.com/bogdan-largeanu/blog-demo-typescript-apigatewayv2-authroizer