API Gateway with Custom Lambda Authorizer and Amazon Cognito by example

Protected_Api

Offloading authentication and authorization logic from your application to AWS API Gateway (APIGW) is a pretty cool feature that a lot of companies are looking into nowadays. It has a few undeniable benefits:

  • Authentication logic consolidation - Authentication/Authorization logic is decoupled from your Application and can be updated/changed in one place.
  • Caching can be configured and in turn it will help to reduce load on your Identity Provider (IdP)
  • Repeatable downstream backend integration protection

Why Custom Lambda Authorizers:

  • Can be used with single or multiple backends
  • Can be used when APIGW is configured as a proxy to other AWS sercices (Like S3 or DynamoDB etc.)
  • Can run from a central “Security” account - Centralizing your AuthN and AuthZ functionality in case of multi-account architecture

A few months ago I was looking for examples of end-to-end implementation of API Gateway with Custom Lambda Authorizer and Amazon Cognito. For some of you that aren’t familiar with Amazon Cognito please read about it here.
In this example we’ll be using Amazon Cognito User Pools as our user directory. In the Enterprize setup I would advise to use Cognito coupled with external IdP (Examples of external IdPs - Okta, AD, Auth0) - I’m planning to write another post on Amazon Cognito with AD integration in one of our next blog posts and look at pros and cons in using Amazon Cognito by itself vs Amazon Cognito with IdP.

Before we start diving into it I’d like to mention a couple of useful blogs and give credit where credit’s due - I recommend reading at least those 2 articles to get a general feel as to why I’m configuring things the way I do and to get a good background info on Custom Authorizers and OAuth 2.0 grants:

Let’s create our resources and see how it all hangs together.

  1. Cognito User Pool - cognito-userpool.yaml
    We will configure a few standard attributes and a custom attribute (custom:upload_folder) as an example of custom attribute, let’s say we want each user to have an “upload folder” - prefix in the S3 bucket.
AWSTemplateFormatVersion: '2010-09-09'
Description: 'Cognito User Pool Blog Example'

Mappings:
  # Per acc config
  AccountConfig:
    '123456789123':
      ExtZone: example.com
      ExtZoneId: ZZZU3243HZZZZZ
      DefaultCert: "arn:aws:acm:us-east-1:123456789123:certificate/87a97ab2-64a5-4090-9041-1234567891234"

Resources:

# BEGIN - user pools
    userPool:
      Type: AWS::Cognito::UserPool
      Properties:
        UserPoolName: !Sub "${AWS::StackName}"
        AutoVerifiedAttributes:
          - email
        UsernameAttributes:
          - email
        AccountRecoverySetting:
          RecoveryMechanisms: 
            - 
              Name: admin_only
              Priority: 1
        VerificationMessageTemplate:
          DefaultEmailOption: CONFIRM_WITH_LINK
        EmailVerificationSubject: Verify your account
        EmailVerificationMessage: Your verification code is <b>{####}</b>.
        Policies:
          PasswordPolicy:
            MinimumLength: 10
            RequireLowercase: false
            RequireNumbers: false
            RequireSymbols: false
            RequireUppercase: false
        AdminCreateUserConfig:
          AllowAdminCreateUserOnly: true
        Schema:
          -
            Name: email
            Mutable: true
            Required: true
          -
            AttributeDataType: String
            Name: upload_folder
            Mutable: true
            Required: false
          -
            Name: family_name
            Mutable: true
            Required: true
          -
            Name: given_name
            Mutable: true
            Required: true
          -
            Name: preferred_username
            Mutable: true
            Required: true
# END - user pools

## BEGIN - user pool domains - Finish testing and add Company's domain config
    # Unfortunately I can't create Alias in the Hosted Zone here as CFN doesn't support it yet
    # Bear in mind - You will need to have an A record for your root domain in the Public zone
    # You still need to create an Alias in your Hosted Zone to point to the cloudfront URL - get it from the console or cli - or do it via custom resource
    # https://github.com/aws-cloudformation/aws-cloudformation-coverage-roadmap/issues/356
    # https://github.com/aws-cloudformation/aws-cloudformation-coverage-roadmap/issues/58
    userPoolDomain:
      Type: AWS::Cognito::UserPoolDomain 
      Properties:
        UserPoolId: !Ref userPool
        Domain: !Sub
          - "${AWS::StackName}.${DNS_ZONE}"
          - {DNS_ZONE: !FindInMap [AccountConfig, !Ref 'AWS::AccountId', ExtZone]}
        CustomDomainConfig:
             CertificateArn: !FindInMap [AccountConfig, !Ref 'AWS::AccountId', DefaultCert]
# END - user pool domains

Outputs:
  StackName:
    Description: 'Stack name.'
    Value: !Sub '${AWS::StackName}'

  userPoolId:
    Export:
      Name: !Sub '${AWS::StackName}-userPoolId'
    Description: User Pool ID
    Value: !Ref userPool

Let’s deploy it - run this in cli:

REGION="ap-southeast-2"
DEPLOY_BUCKET=deployment-templates-bucket-private
STACK_NAME="user-pool-blog"

aws cloudformation package \
    --region "${REGION}"  \
    --template-file cognito-userpool.yaml \
    --s3-bucket "${DEPLOY_BUCKET}" \
    --s3-prefix "cloudformation/${STACK_NAME}/package/$(date +%s)" \
    --output-template-file cognito-userpool-packaged-template.yaml

aws cloudformation deploy --template-file cognito-userpool-packaged-template.yaml \
    --region "${REGION}"  \
    --stack-name "${STACK_NAME}" \
    --no-fail-on-empty-changeset \
    --s3-bucket "${DEPLOY_BUCKET}" \
    --s3-prefix "cloudformation/${STACK_NAME}/template/$(date +%s)" \
    --capabilities CAPABILITY_IAM

I am using a Custom Domain with my User Pool, so I need to quickly jump into Route53 and create and ALIAS record: user-pool-blog.example.com -> d1h4chg8tp21la.cloudfront.net DomainName As you can see you’d need a certificate in us-east-1 (as it is a cloudfront distribution that sits in front of your User Pool)

  1. Cognito App Client - app-client.yaml
    We will be using “Implicit grant” AuthFlow so we could grab JWT token directly for our tests. In production you’d want to use “Authorization code grant” AuthFlow - Our Frontend UI will allow us to Sign-In, get the authorization code and exchange it for user pool token - this way tokens aren’t exposed to the user directly and there is less chance to be compromised.
    Set-up Application’s Callback URL(s) and Sign out URL(s) for your Frontend as per example below:
AWSTemplateFormatVersion: '2010-09-09'
Description: 'Cognito App Client Blog Example'

Parameters:

  ClientName:
    Description: 'Cognito ClientName'
    Type: String
    AllowedValues:
    - Blog-Client

Mappings:
  # Per env config
  EnvConfig:
    'Blog-Client':
      CallbackURLs: 
        - https://localhost
        - https://localhost:3000/auth
        - http://localhost:3000/signin
        - https://blogapp.example.com/signin
      LogoutURLs:
        - https://localhost
        - https://localhost:3000/auth
        - http://localhost:3000/signout
        - https://blogapp.example.com/signout
      UserPoolExportName: user-pool-blog-userPoolId

Resources:

# BEGIN - clients
    userPoolClient:
      Type: AWS::Cognito::UserPoolClient
      Properties: 
        AllowedOAuthFlows: 
          #- code
          - implicit
        AllowedOAuthFlowsUserPoolClient: true
        AllowedOAuthScopes: 
          - phone
          - email
          - openid
          - profile
        CallbackURLs: !FindInMap [EnvConfig, !Ref ClientName, CallbackURLs]
        ClientName: !Ref ClientName
        DefaultRedirectURI: https://localhost
        GenerateSecret: false
        LogoutURLs: !FindInMap [EnvConfig, !Ref ClientName, LogoutURLs]
        PreventUserExistenceErrors: ENABLED
        UserPoolId: 
              Fn::ImportValue: !FindInMap [EnvConfig, !Ref ClientName, UserPoolExportName]
        ReadAttributes: 
          - preferred_username
          - given_name
          - family_name
          - custom:upload_folder
          - email
        WriteAttributes: 
          - preferred_username
          - given_name
          - family_name
          - custom:upload_folder
          - email
        SupportedIdentityProviders:
          - COGNITO
# END - clients

Outputs:
  StackName:
    Description: 'Stack name.'
    Value: !Sub '${AWS::StackName}'

  clientId:
    Description: ID for app client in the User Pool
    Value: !Ref userPoolClient

Let’s deploy it - run this in cli:

REGION="ap-southeast-2"
DEPLOY_BUCKET=deployment-templates-bucket-private
ClientName="Blog-Client"
STACK_NAME="cognito-appclient-blog"

PROP_VALUES="\
ClientName=${ClientName}"

aws cloudformation package \
    --region "${REGION}"  \
    --template-file app-client.yaml \
    --s3-bucket "${DEPLOY_BUCKET}" \
    --s3-prefix "cloudformation/${STACK_NAME}/package/$(date +%s)" \
    --output-template-file app-client-packaged-template.yaml

aws cloudformation deploy --template-file app-client-packaged-template.yaml \
    --region "${REGION}"  \
    --stack-name "${STACK_NAME}" \
    --no-fail-on-empty-changeset \
    --parameter-overrides ${PROP_VALUES} \
    --s3-bucket "${DEPLOY_BUCKET}" \
    --s3-prefix "cloudformation/${STACK_NAME}/template/$(date +%s)" \
    --capabilities CAPABILITY_IAM
  1. Now before getting to Custom Authorizer, let’s create a user in our User Pool, assign value to the custom attribute (custom:upload_folder) and check those JWT tokens: I’ll be using cli to do it quick and dirty:
  • Create a user
  • Set his email_verified status to true
  • Create uploader group
  • Add the user to uploader group
export USERNAME="john.doe@example.com"
export GIVEN_NAME="John"
export FAMILY_NAME="Doe"
export UPLOAD_FOLDER="johndoe-uploadfolder"
export POOL_ID="ap-southeast-2_qr7GA6s5T"

aws cognito-idp admin-create-user \
--user-pool-id ${POOL_ID} \
--username ${USERNAME} \
--user-attributes \
Name=email,Value=${USERNAME} \
Name=given_name,Value=${GIVEN_NAME} \
Name=family_name,Value=${FAMILY_NAME} \
Name=preferred_username,Value=${USERNAME} \
Name=custom:upload_folder,Value=${UPLOAD_FOLDER} \
--region ap-southeast-2

aws cognito-idp admin-update-user-attributes \
--user-pool-id ${POOL_ID} \
--username ${USERNAME} \
--user-attributes \
Name=email_verified,Value=true \
Name=custom:upload_folder,Value=${UPLOAD_FOLDER} \
--region ap-southeast-2

aws cognito-idp create-group \
--user-pool-id ${POOL_ID} \
--group-name uploader \
--description "Allows file uploads" \
--region ap-southeast-2

aws cognito-idp admin-add-user-to-group \
--user-pool-id ${POOL_ID} \
--username ${USERNAME} \
--group-name uploader \
--region ap-southeast-2

You should have received temporary password to your email adress (email address of user you’ve just created): Temp_Password

Now you can either go to the Cognito Console: “App integration”->"App client settings” and click on “Launch Hosted UI” or go to the following URL (Replace Domain and App Client Id with yours): https://user-pool-blog.example.com/login?response_type=token&client_id=3vf80uftfiegiqd1d8iaihfbq5&redirect_uri=https://localhost

Login and Change Password (You will be forced to change it on first login): UI_Login

And now we have our id_token and access_token: ID_Token

https://localhost/#id_token=eyJraWQiOiJOcmp2S2RlV3hjVXNySFhjcUdUVXJ5OVB2N1RET2Vzek9HcWlERWs3czNNPSIsImFsZyI6IlJTMjU2In0.eyJhdF9oYXNoIjoiZ0p0RHBQOVE0S1dlWHFMcGF6UHRxUSIsInN1YiI6ImM2NDM2ZjRiLTEwMmUtNDNjNS1hMzFiLTM5YjM4MTgwZDg5MyIsImNvZ25pdG86Z3JvdXBzIjpbInVwbG9hZGVyIl0sImlzcyI6Imh0dHBzOlwvXC9jb2duaXRvLWlkcC5hcC1zb3V0aGVhc3QtMi5hbWF6b25hd3MuY29tXC9hcC1zb3V0aGVhc3QtMl9xcjdHQTZzNVQiLCJjb2duaXRvOnVzZXJuYW1lIjoiYzY0MzZmNGItMTAyZS00M2M1LWEzMWItMzliMzgxODBkODkzIiwicHJlZmVycmVkX3VzZXJuYW1lIjoibGVvbi5rb2xjaGluc2t5QG1hbnRhbHVzLmNvbSIsImdpdmVuX25hbWUiOiJMZW9uIiwiYXVkIjoiM3ZmODB1ZnRmaWVnaXFkMWQ4aWFpaGZicTUiLCJldmVudF9pZCI6ImQwMzY3ZGQ5LTZjNzItNDVhYy05Njc1LWY1ZTM3NWM4YzAwYSIsInRva2VuX3VzZSI6ImlkIiwiYXV0aF90aW1lIjoxNTkwMTI0ODE4LCJjdXN0b206dXBsb2FkX2ZvbGRlciI6Imxlb25rb2xjaGluc2t5LXVwbG9hZGZvbGRlciIsImV4cCI6MTU5MDEyODQxOCwiaWF0IjoxNTkwMTI0ODE4LCJmYW1pbHlfbmFtZSI6IktvbGNoaW5za3kiLCJlbWFpbCI6Imxlb24ua29sY2hpbnNreUBtYW50YWx1cy5jb20ifQ.EGJODxijBO03Y2tpffp8fmSLYVJiNkRhHW6rz4Yy4cC2hGFQVbiWM0nEAUKU6VSsC8zwvp-uYZPc1RA_qLWQ1kfSr-gpRI2wrx0FPPrhtuWt3qw2mpVMUbIxqgrKDKi6CeQOgGZtoN9GcKdEbEDViqo9dQMiqfgwglzw7X-VmGqAEel4eraKsjkP-Stqmdimd-TRsOBudj1QySI0MvgioywYRNFNnpRNOhB-_nTwO-vm9fxO8T7e767TQUP9QDRWnC6iNFVbh7CmYNjsLBHzV45nlFg60tJTSh01CD5oN2P6UNIJqxjEKasb_9Ek-A8bENH_wcvbLCUqlXMd0x3vYw&access_token=eyJraWQiOiJBbHV3YWVEU1wvWitMT3BtVEVzOXA0WDZcL2Iyb3cyYXB1OGJBVzFEVk1kaFE9IiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiJjNjQzNmY0Yi0xMDJlLTQzYzUtYTMxYi0zOWIzODE4MGQ4OTMiLCJjb2duaXRvOmdyb3VwcyI6WyJ1cGxvYWRlciJdLCJpc3MiOiJodHRwczpcL1wvY29nbml0by1pZHAuYXAtc291dGhlYXN0LTIuYW1hem9uYXdzLmNvbVwvYXAtc291dGhlYXN0LTJfcXI3R0E2czVUIiwidmVyc2lvbiI6MiwiY2xpZW50X2lkIjoiM3ZmODB1ZnRmaWVnaXFkMWQ4aWFpaGZicTUiLCJldmVudF9pZCI6ImQwMzY3ZGQ5LTZjNzItNDVhYy05Njc1LWY1ZTM3NWM4YzAwYSIsInRva2VuX3VzZSI6ImFjY2VzcyIsInNjb3BlIjoicGhvbmUgb3BlbmlkIHByb2ZpbGUgZW1haWwiLCJhdXRoX3RpbWUiOjE1OTAxMjQ4MTgsImV4cCI6MTU5MDEyODQxOCwiaWF0IjoxNTkwMTI0ODE4LCJqdGkiOiI3ZjVlMmYyZS00NWY0LTQ0NWYtYWQ1Ni0yYzU1MDk4MGI1NzciLCJ1c2VybmFtZSI6ImM2NDM2ZjRiLTEwMmUtNDNjNS1hMzFiLTM5YjM4MTgwZDg5MyJ9.Cu-rBjkFXn9TvXNYGjEuK2_QSndvPQroldeD4UI2VC8mDk2_38CffpsTdaVzFt1SXEoNGDxv8x28EuP3BjPI20S69ctLWA1Luod7zhvN9tGD6xZQMYmjm7Oa5gIe3nbFEFScWbbebYfNnAnEOdfdQ6djzNDtuQRo5h2eagjwyfvqcCYt7DD0QuSf-goLWT0AXS-ahrbajNSLUyoTZT18HrN8eRzdNfVqjzOSKfGcSyYxpE9LYtnLYR0Lj1HpKDSFFYrxp5mEE_E77fvUnbbIjuS1EkM618d8NoDg-R8mzS3n6lhTmmGSW55uiORFFJvXfXWlJ5oAvA2VRkCb1b0LQw&expires_in=3600&token_type=Bearer

We don’t have refresh_token because we’ve used “Implicit Grant”.

Now let’s quickly go to https://jwt.io/ and decode our tokens: JWT_IO

id_token

{
  "at_hash": "gJtDpP9Q4KWeXqLpazPtqQ",
  "sub": "c6436f4b-102e-43c5-a31b-39b38180d893",
  "cognito:groups": [
    "uploader"
  ],
  "iss": "https://cognito-idp.ap-southeast-2.amazonaws.com/ap-southeast-2_qr7GA6s5T",
  "cognito:username": "c6436f4b-102e-43c5-a31b-39b38180d893",
  "preferred_username": "john.doe@example.com",
  "given_name": "John",
  "aud": "3vf80uftfiegiqd1d8iaihfbq5",
  "event_id": "d0367dd9-6c72-45ac-9675-f5e375c8c00a",
  "token_use": "id",
  "auth_time": 1590124818,
  "custom:upload_folder": "johndoe-uploadfolder",
  "exp": 1590128418,
  "iat": 1590124818,
  "family_name": "Doe",
  "email": "john.doe@example.com"
}

access_token

{
  "sub": "c6436f4b-102e-43c5-a31b-39b38180d893",
  "cognito:groups": [
    "uploader"
  ],
  "iss": "https://cognito-idp.ap-southeast-2.amazonaws.com/ap-southeast-2_qr7GA6s5T",
  "version": 2,
  "client_id": "3vf80uftfiegiqd1d8iaihfbq5",
  "event_id": "d0367dd9-6c72-45ac-9675-f5e375c8c00a",
  "token_use": "access",
  "scope": "phone openid profile email",
  "auth_time": 1590124818,
  "exp": 1590128418,
  "iat": 1590124818,
  "jti": "7f5e2f2e-45f4-445f-ad56-2c550980b577",
  "username": "c6436f4b-102e-43c5-a31b-39b38180d893"
}

Pro tip:
As you can see id_token and access_token differ and quite a few of the user’s details are missing from access_token. Well, there is no rule of thumb that will dictate what token to use and when, but usually when you don’t need to pass user’s details to the downstream service you’d prefer to use access_token as a more secure option that doesn’t share user details, access_token is often used in case of service to service authentication. If you want to use access_token and still want to get a subset of user details in the JWT token you can use a nifty Cognito feature called “Triggers”. For example if you have Cognito+ADFS integration in place and your users have “custom:groups” attribute which value you’d want to add to access_token you could use “Pre Token Generation” workflow - Basically create a lambda that will inject “custom:groups” value into “cognito:groups” of your access_token before token is returned to your App/User. An example of such lambda can be found in this repo - /lambda/pretokengeneration

  1. Let’s create Custom Lambda Authorizer and then test it with a sample App. I’m using serverless framework and nodejs throughout this blogpost so you might want to quickly familiarize yourself with those.

To show you some of the flexibilty you have with Custom Authorizers - I will add a little twist in the Custom Lambda Authorizer - I’d also want to check if the user is a member in the “uploader” group - If he is not then he won’t be successfuly authenticated.

In the authlambda-blog folder:

.
├── auth.js
├── package.json
└── serverless.yml

auth.js

'use strict';

const request = require('request');
const jwkToPem = require('jwk-to-pem');
const jwt = require('jsonwebtoken');
global.fetch = require('node-fetch');

const appAccessGroup = process.env['APP_ACCESS_GROUP'];
const UserPoolIdValue = process.env['USER_POOL_ID']
const ClientIdValue = process.env['CLIENT_ID']

// Pool Info
const poolData = {    
    UserPoolId : UserPoolIdValue, // Your user pool id here    
    ClientId : ClientIdValue // Your client id here
    }; 
const pool_region = 'ap-southeast-2';

/**
  * Returns an IAM policy document for a given user and resource.
  *
  * @method buildIAMPolicy
  * @param {String} userId - user id
  * @param {String} effect  - Allow / Deny
  * @param {String} resource - resource ARN
  * @param {String} context - response context
  * @returns {Object} policyDocument
  */
 const buildIAMPolicy = (userId, effect, resource, context) => {
  console.log(`buildIAMPolicy ${userId} ${effect} ${resource}`);
  const policy = {
    principalId: userId,
    policyDocument: {
      Version: '2012-10-17',
      Statement: [
        {
          Action: 'execute-api:Invoke',
          Effect: effect,
          Resource: resource,
        },
      ],
    },
    context,
  };

  console.log(JSON.stringify(policy));
  return policy;
};
//
// Reusable Authorizer function, set on `authorizer` field in serverless.yml
module.exports.authorize = (event, context, cb) => {

  console.log('Auth function invoked');
  if (event.authorizationToken) {
    // Remove 'bearer ' from token:
    const token = event.authorizationToken.substring(7);
    // Make a request to the iss + .well-known/jwks.json URL:
    request({
      url: `https://cognito-idp.${pool_region}.amazonaws.com/${poolData.UserPoolId}/.well-known/jwks.json`,
      json: true
    }, function (error, response, body) {
          if (!error && response.statusCode === 200) {
              var pems = {};
              var keys = body['keys'];
              for(var i = 0; i < keys.length; i++) {
                  //Convert each key to PEM
                  var key_id = keys[i].kid;
                  var modulus = keys[i].n;
                  var exponent = keys[i].e;
                  var key_type = keys[i].kty;
                  var jwk = { kty: key_type, n: modulus, e: exponent};
                  var pem = jwkToPem(jwk);
                  pems[key_id] = pem;
              }
              //validate the token
              var decodedJwt = jwt.decode(token, {complete: true});
              if (!decodedJwt) {
                  console.log("Not a valid JWT token");
                  cb('Unauthorized');
              }

              var kid = decodedJwt.header.kid;
              var pem = pems[kid];
              if (!pem) {
                  console.log('Invalid token');
                  cb('Unauthorized');
              }

              jwt.verify(token, pem, function(err, payload) {
                  if(err) {
                      console.log("Invalid Token.");
                      cb('Unauthorized');
                  } else {
                      // START - appAccessGroup check - Adding appAccessGroup to allow initial access to the Api based on the group membership
                      if (typeof payload['cognito:groups'] === 'undefined' || payload['cognito:groups'] === null) {
                        console.log("No cognito:groups in the payload hence can't look for APP_ACCESS_GROUP in the array of cognito:groups which is a list of user groups, hence returning UNAUTHORISED");
                        cb('Unauthorized');
                      } else {
                        var customScope = payload['cognito:groups'];
                        if(customScope.includes(appAccessGroup)) {
                          console.log(appAccessGroup + " is in the customScope list " + customScope);
                        } else {
                          console.log("Error! " + appAccessGroup + " is NOT in the customScope list " + customScope);
                          console.log("Please make sure that the user is a member of APP_ACCESS_GROUP env variable and claim rule added to ADFS config to pass group membership into a custom  attribute - APP_ACCESS_GROUP is env variable for the App authoriser lambda");
                          cb('Unauthorized');
                        }
                      }
                      // END - appAccessGroup check
                      console.log("methodArn:");
                      console.log(event.methodArn);
                      // Let's return the full payload in authorizerContext - this is really up to company's requirements
                      const authorizerContext = { token: JSON.stringify(payload) };
                      // You can replace * with event.methodArn if you don't want to manage user access in the backend app based on the user scope (and face the consequences ;)
                      const policyDocument = buildIAMPolicy(payload.sub, 'Allow', '*', authorizerContext);
                      cb(null, policyDocument);
                  }
              });
          } else {
              console.log("Error! Unable to download JWKs");
              cb('Unauthorized');
          }
      });
  } else {
    console.log('No authorizationToken found in the header.');
    cb('Unauthorized');
  }
};

package.json

{
  "name": "authlambda-blog",
  "version": "1.0.0",
  "description": "Custom Authoriser",
  "scripts": {
    "deploy-lambda": "node_modules/.bin/serverless deploy --force -v",
    "remove-lambda": "node_modules/.bin/serverless remove -v"
  },
  "author": "",
  "license": "MIT",
  "dependencies": {
    "jsonwebtoken": "^8.5.1",
    "jwk-to-pem": "^2.0.1",
    "node-fetch": "^2.6.0",
    "request": "^2.88.0"
  },
  "devDependencies": {
    "serverless": "^1.67.0",
    "serverless-stack-termination-protection": "^1.0.0"
  }
}

serverless.yml (Update UserPoolId and ClientId to your values):
UserPoolId:
dev: ‘ap-southeast-2_qr7GA6s5T’ # UserPoolId in the user-pool-blog
ClientId:
dev: “3vf80uftfiegiqd1d8iaihfbq5” # App client Blog-Client \

service: authlambda-blog

frameworkVersion: ">=1.1.0 <2.0.0"

plugins:
  - serverless-stack-termination-protection

custom:
  serverlessTerminationProtection:
    stages:
      - prod # prod - Production
  logRetention:
    prod: 90
    default: 7
  UserPoolId:
      dev: 'ap-southeast-2_qr7GA6s5T' # UserPoolId in the user-pool-blog
  ClientId:
      dev: "3vf80uftfiegiqd1d8iaihfbq5" # App client Blog-Client

  # appname and app_access_group - variables passed on cli (if not passed - defaults are used as below)
  default_appname: dummyapp
  appname: ${opt:appname, self:custom.default_appname}
  default_app_access_group: uploader
  app_access_group: ${opt:app_access_group, self:custom.default_app_access_group}

  tags:
    team: leons
    platform: it
    service: ${self:service}
    environment: ${self:provider.stage}
  stackTags:  
    team: leons
    platform: it
    service: ${self:service}
    environment: ${self:provider.stage}

provider:
  name: aws
  runtime: nodejs10.x
  stackName: ${self:service}-${self:provider.stage}-${self:custom.appname}
  memorySize: 128
  stage: ${opt:stage, env:STAGE_NAME, 'poc' }
  deploymentBucket: deployment-templates-bucket-private
  timeout: 30
  logRetentionInDays: ${self:custom.logRetention.${self:provider.stage}, self:custom.logRetention.default}
  region: ap-southeast-2
  tracing:
    apiGateway: true
    lambda: true # Optional, can be true (true equals 'Active'), 'Active' or 'PassThrough'
  resultTtlInSeconds: 3600

package:
  exclude:
  - node_modules/aws-sdk/**

functions:
  auth:
    handler: auth.authorize
    name: ${self:service}-${self:provider.stage}-${self:custom.appname}-auth
    role: myCustRole
    cors: true
    environment:
      APP_ACCESS_GROUP: ${self:custom.app_access_group}
      USER_POOL_ID: ${self:custom.UserPoolId.${self:provider.stage}}
      CLIENT_ID: ${self:custom.ClientId.${self:provider.stage}}

resources:
  Resources:
    # Custom role RoleName is tied to "appname" due to the way serverless framework constructs "rolename" - ${self:service}-${self:provider.stage}-[region]-lambdaRole
    # Since we're going to have multiple lambdas of the same service and stage we need to make sure the role for each named differently
    myCustRole:
      Type: AWS::IAM::Role
      Properties:
        Path: /cognito/authoriser/${self:custom.appname}/
        RoleName: ${self:service}-${self:provider.stage}-${self:custom.appname}-auth # required if you want to use 'serverless deploy --function' later on
        AssumeRolePolicyDocument:
          Version: '2012-10-17'
          Statement:
            - Effect: Allow
              Principal:
                Service:
                  - lambda.amazonaws.com
              Action: sts:AssumeRole
        # note that these rights are needed if you want your function to be able to communicate with resources within your vpc
        # Just to get rid of that annoying error message in the console for now - https://github.com/serverless/serverless/issues/6241 (there are links to open issues)
        # But to be honest serverless framework is doing a very similar thing with arn:aws:iam::aws:policy/service-role/AWSLambdaVPCAccessExecutionRole, so it is a legit way :)
        ManagedPolicyArns:
          - "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
        Policies:
          - PolicyName: xray-policy
            PolicyDocument:
              Version: '2012-10-17'
              Statement:
                - Effect: Allow
                  Action:
                    - xray:PutTraceSegments
                    - xray:PutTelemetryRecords
                  Resource:
                    - "*"

    # List Lambda Permissions for all accounts you want to use this authoriser lambda with (to be invoked by Authorisers from different accounts)
    # Access for new accounts should be added below - meaning that authorizers from diff. accounts could invoke Authorizer function
    ConfigLambdaPermission123456789123:
      Type: "AWS::Lambda::Permission"
      Properties:
        Action: lambda:InvokeFunction
        FunctionName: !Ref AuthLambdaFunction
        Principal: apigateway.amazonaws.com
        SourceArn: !Sub "arn:aws:execute-api:ap-southeast-2:123456789123:*/authorizers/*"

Let’s deploy Custom Authorizer ($ npm run deploy-lambda -- --stage ${stage} --appname ${appname} --app_access_group ${app_access_group}):

$ npm install
$ npm run deploy-lambda -- --stage dev --appname testapp --app_access_group uploader

And we’ve got ourselves a Lambda Authorizer:

AuthLambdaFunctionQualifiedArn: arn:aws:lambda:ap-southeast-2:123456789123:function:authlambda-blog-dev-testapp-auth
  1. OK, we’re almost there. Let’s just quickly create a demo App with one endpoint protected by Custom Authorizer and another unprotected endpoint.

In the cognito-auth-demo-app folder:

$ tree
.
├── package.json
├── serverless.yml
└── src
    ├── private.js
    └── public.js

public.js

'use strict';

const serverless = require('serverless-http');
const express = require('express');
var cors = require('cors');
const app = express();

// You can also enable pre-flight across-the-board
// https://expressjs.com/en/resources/middleware/cors.html
app.options('*', cors())

var corsOptions = {
    origin: '*',
    methods: [ 'GET, HEAD, PUT, PATCH, POST, DELETE, OPTIONS' ],
    credentials: true,
    preflightContinue: false,
    optionsSuccessStatus: 200
  };

app.get('/public', cors(corsOptions), function (req, res) {
    res.send(new Buffer('<h5>Hello Mantalus Public Page!</h5>'));
});

module.exports.handler = serverless(app);

private.js

'use strict';

const serverless = require('serverless-http');
const express = require('express');
var cors = require('cors');
const app = express();
var bodyParser = require('body-parser');
var cookieParser = require('cookie-parser');
const CircularJSON = require('circular-json');

app.use(bodyParser.urlencoded({ extended: false }));
app.use(bodyParser.json());
app.use(cookieParser());

// You can also enable pre-flight across-the-board
// https://expressjs.com/en/resources/middleware/cors.html
app.options('*', cors())

var whitelist = [
    'http://localhost:3000',
    'http://localhost'
]

var corsOptions = {
  origin: function (origin, callback) {
    if (whitelist.indexOf(origin) !== -1 || !origin) {
      callback(null, true)
    } else {
      callback(new Error('Not allowed by CORS'))
    }
  },
  methods: [ 'GET, HEAD, PUT, PATCH, POST, DELETE, OPTIONS' ],
  credentials: true,
  preflightContinue: false,
  optionsSuccessStatus: 200
}

app.use('/private',function(req, res, next){
  console.log("A new request received at " + Date.now());
  console.log('%O', req);
  console.log('req:\n' + CircularJSON.stringify(req));
  next();
});


app.get('/private', cors(corsOptions), function (req, res) {
    res.send('<h5>Hello Mantalus Private Page!</h5>');
});


module.exports.handler = serverless(app);

package.json

{
  "name": "demoapp-auth-blog",
  "version": "1.0.0",
  "description": "Demo App",
  "scripts": {
    "deploy-lambda": "node_modules/.bin/serverless deploy --force -v",
    "remove-lambda": "node_modules/.bin/serverless remove -v"
  },
  "author": "",
  "license": "MIT",
  "dependencies": {
    "body-parser": "^1.19.0",
    "circular-json": "^0.5.9",
    "cookie-parser": "^1.4.4",
    "cors": "^2.8.5",
    "express": "^4.17.1",
    "serverless-http": "^2.3.1"
  },
  "devDependencies": {
    "serverless-pseudo-parameters": "^2.5.0"
  }
}

serverless.yml
Notice the apiGatewayAuthorizer section - that’s there we reference our Custom Authorizer Lambda (that can live in this or any other account)

service: demoapp-auth-blog

frameworkVersion: ">=1.1.0 <2.0.0"

plugins:
  - serverless-pseudo-parameters

provider:
  name: aws
  runtime: nodejs10.x
  memorySize: 128
  stage: ${opt:stage, env:STAGE_NAME, 'poc' }
  deploymentBucket: deployment-templates-bucket-private
  timeout: 30
  logRetentionInDays: ${self:custom.logRetention.${self:provider.stage}, self:custom.logRetention.default}
  region: ap-southeast-2
  tracing:
    apiGateway: true
    lambda: true # Optional, can be true (true equals 'Active'), 'Active' or 'PassThrough'
  resultTtlInSeconds: 0 # Adjust for caching purposes after initial testing is done

  tags:
    team: leons
    platform: it
    service: ${self:service}
    environment: ${self:provider.stage}
  stackTags:  
    team: leons
    platform: it
    service: ${self:service}
    environment: ${self:provider.stage}

custom:
  logRetention:
    prod: 90    
    default: 7

package:
  exclude:
  - node_modules/aws-sdk/**

functions:
  publicEndpoint:
    handler: src/public.handler
    events:
      - http:
          path: /public
          method: GET
          integration: lambda
          cors: true

  privateEndpoint:
    handler: src/private.handler
    events:
      - http: 
          path: /private
          method: GET
          integration: lambda
          authorizer:
            type: "CUSTOM"
            authorizerId:
              Ref: "apiGatewayAuthorizer"
            resultTtlInSeconds: ${self:provider.resultTtlInSeconds}
          cors: true

resources:
  Resources:
    GatewayResponseDefault4XX:
      Type: 'AWS::ApiGateway::GatewayResponse'
      Properties:
        ResponseParameters:
          gatewayresponse.header.Access-Control-Allow-Origin: "'*'"
          gatewayresponse.header.Access-Control-Allow-Headers: "'*'"
        ResponseType: DEFAULT_4XX
        RestApiId:
          Ref: 'ApiGatewayRestApi'

    GatewayResponseDefault5XX:
      Type: 'AWS::ApiGateway::GatewayResponse'
      Properties:
        ResponseParameters:
          gatewayresponse.header.Access-Control-Allow-Origin: "'*'"
          gatewayresponse.header.Access-Control-Allow-Headers: "'*'"
        ResponseType: DEFAULT_5XX
        RestApiId:
          Ref: 'ApiGatewayRestApi'

    apiGatewayAuthorizer:
      Type: "AWS::ApiGateway::Authorizer"
      Properties:
        Name: "authorizer"
        # Replace authlambda-blog-dev-testapp-auth with your Authoriser Lambda name
        AuthorizerUri: "arn:aws:apigateway:#{AWS::Region}:lambda:path/2015-03-31/functions/arn:aws:lambda:#{AWS::Region}:123456789123:function:authlambda-blog-dev-testapp-auth/invocations"
        IdentityValidationExpression:  ^Bearer +[-0-9a-zA-Z\._]*$
        RestApiId: !Ref "ApiGatewayRestApi"
        Type: "TOKEN"
        IdentitySource: "method.request.header.Authorization"
      DependsOn:
        - "ApiGatewayRestApi"

Let’s deploy this demo App (npm run deploy-lambda -- --stage ${stage}):

$ npm install
$ npm run deploy-lambda -- --stage dev

We’ve got our endpoints now:

endpoints:
  GET - https://yijhzu5fig.execute-api.ap-southeast-2.amazonaws.com/dev/public
  GET - https://yijhzu5fig.execute-api.ap-southeast-2.amazonaws.com/dev/private

Let’s check those out:

$ curl https://yijhzu5fig.execute-api.ap-southeast-2.amazonaws.com/dev/public
{"statusCode":200,"headers":{"x-powered-by":"Express","access-control-allow-origin":"*","access-control-allow-credentials":"true","content-type":"application/octet-stream","content-length":"36","etag":"W/\"24-TX3eE0StJ1bfkNdwhw4LuJZ/wHI\""},"isBase64Encoded":false,"body":"<h5>Hello Mantalus Public Page!</h5>"}

$ curl https://yijhzu5fig.execute-api.ap-southeast-2.amazonaws.com/dev/private
{"message":"Unauthorized"}

OK, Let’s add Authorization header and check our private endpoint again:

$ curl --header "Authorization:Bearer eyJraWQiOiJOcmp2S2RlV3hjVXNySFhjcUdUVXJ5OVB2N1RET2Vzek9HcWlERWs3czNNPSIsImFsZyI6IlJTMjU2In0.eyJhdF9oYXNoIjoieWxRYmVjc0lVbF9BM1RHTU1Vb01EdyIsInN1YiI6ImM2NDM2ZjRiLTEwMmUtNDNjNS1hMzFiLTM5YjM4MTgwZDg5MyIsImNvZ25pdG86Z3JvdXBzIjpbInVwbG9hZGVyIl0sImlzcyI6Imh0dHBzOlwvXC9jb2duaXRvLWlkcC5hcC1zb3V0aGVhc3QtMi5hbWF6b25hd3MuY29tXC9hcC1zb3V0aGVhc3QtMl9xcjdHQTZzNVQiLCJjb2duaXRvOnVzZXJuYW1lIjoiYzY0MzZmNGItMTAyZS00M2M1LWEzMWItMzliMzgxODBkODkzIiwicHJlZmVycmVkX3VzZXJuYW1lIjoibGVvbi5rb2xjaGluc2t5QG1hbnRhbHVzLmNvbSIsImdpdmVuX25hbWUiOiJMZW9uIiwiYXVkIjoiM3ZmODB1ZnRmaWVnaXFkMWQ4aWFpaGZicTUiLCJldmVudF9pZCI6ImQ0MmE4OWJkLWI4YjAtNDJjZi1iNmYxLWM0MjE5MGFmMjU4ZiIsInRva2VuX3VzZSI6ImlkIiwiYXV0aF90aW1lIjoxNTkwMTMyNjE5LCJjdXN0b206dXBsb2FkX2ZvbGRlciI6Imxlb25rb2xjaGluc2t5LXVwbG9hZGZvbGRlciIsImV4cCI6MTU5MDEzNjIxOSwiaWF0IjoxNTkwMTMyNjE5LCJmYW1pbHlfbmFtZSI6IktvbGNoaW5za3kiLCJlbWFpbCI6Imxlb24ua29sY2hpbnNreUBtYW50YWx1cy5jb20ifQ.exrTYXDRrpDHuEyxXCIsXWyYQL6Jf0QEeVmDTWFGJNhL_MmL6Mf3pCZe1-nzYXg5Jp6cUgJ9oG67gHkNvCOO9Fa0cCgslaGJdLe9AMw4zrmSroBY5OtXbm9MUudjt40dG-Y8cgZ0sKydwhzJ6G4Gn78ExgPSklySYPiREKbptDVAIMAwnuU5yYja4-W5G3IlR7gYKJUSwOJSpb_Y-dHETOq1njibWlqc_DU9Aat7Xon84MTCBR51nbRA8mWtC6hbgbVVeiAvY0izodAcWhFVZX9NJ87aIkSeA2ocyKBBCgSw9sV2J99nPz1tLz6JQY5DKX4RhIxMgAm6GXFjCg4hWw" https://yijhzu5fig.execute-api.ap-southeast-2.amazonaws.com/dev/private
{"statusCode":200,"headers":{"x-powered-by":"Express","vary":"Origin","access-control-allow-credentials":"true","content-type":"text/html; charset=utf-8","content-length":"37","etag":"W/\"25-C67pbfBqyV1mc99UfDAOjwGwXWw\""},"isBase64Encoded":false,"body":"<h5>Hello Mantalus Private Page!</h5>"}

Much better :)

Check out Custom Authorizer logs now - That’s how our generated Access policy looks like:

{
    "principalId": "c6436f4b-102e-43c5-a31b-39b38180d893",
    "policyDocument": {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Action": "execute-api:Invoke",
                "Effect": "Allow",
                "Resource": "*"
            }
        ]
    },
    "context": {
        "token": "{\"at_hash\":\"ylQbecsIUl_A3TGMMUoMDw\",\"sub\":\"c6436f4b-102e-43c5-a31b-39b38180d893\",\"cognito:groups\":[\"uploader\"],\"iss\":\"https://cognito-idp.ap-southeast-2.amazonaws.com/ap-southeast-2_qr7GA6s5T\",\"cognito:username\":\"c6436f4b-102e-43c5-a31b-39b38180d893\",\"preferred_username\":\"john.doe@example.com\",\"given_name\":\"John\",\"aud\":\"3vf80uftfiegiqd1d8iaihfbq5\",\"event_id\":\"d42a89bd-b8b0-42cf-b6f1-c42190af258f\",\"token_use\":\"id\",\"auth_time\":1590132619,\"custom:upload_folder\":\"johndoe-uploadfolder\",\"exp\":1590136219,\"iat\":1590132619,\"family_name\":\"Doe\",\"email\":\"john.doe@example.com\"}"
    }
}

Tip: When testing protected endpoints from the browser and your origin domain is different from the App domain - make sure to whitelist your origin in your App similar to what I’ve done in private.js (Well it’s just an example but you get the drift):

var whitelist = [
    'http://localhost:3000',
    'http://localhost'
]

It’s a basic stuff but easy to overlook with all the other Authentication parts you offload to APIGW and Custom Authorizer. Another reason why I’m bringing it up here is because I’ve seen it time and time again with Devs bringing this up - “But I have set the ‘Access-Control-Allow-Origin’ to * which should cover all cases…
Well not exactly. I understand why people overlook it as it’s natural to think that wildcard ‘*’ will cover all cases. But if you read the doco you’ll see that:

The value of “*” is special in that it does not allow requests to supply credentials, meaning it does not allow HTTP authentication, client-side SSL certificates, or cookies to be sent in the cross-domain request.

That means that you have to explicitly whitelist you origin, i.e. ‘Access-Control-Allow-Origin’: ‘http://localhost:3000’

Btw, if you don’t want to use “express framework” demo App above, you could use something as simple as this:

handler.js

'use strict';

const response = {
  statusCode: 200,
  headers: {
    'Access-Control-Allow-Origin': '*',
    'Access-Control-Allow-Credentials': true,
  },
  body: JSON.stringify({
    cartoons: [
      {
        id: 1,
        name: 'Hello Mantalus',
        address: '123 Grange Road',
      },
    ],
  }),
};

// Public API
module.exports.publicEndpoint = (event, context, cb) => {
  console.log(event);
  cb(null, response);
};

// Private API
module.exports.privateEndpoint = (event, context, cb) => {
  console.log(event);
  cb(null, response);
};

And in the serverless.yml demo App example replace functions’ definitions with:

functions:
  publicEndpoint:
    handler: handler.publicEndpoint
    events:
      - http:
          path: api/public
          method: GET
          integration: lambda
          cors: true

  privateEndpoint:
    handler: handler.privateEndpoint
    events:
      - http: 
          path: api/private
          method: GET
          integration: lambda
          authorizer:
            type: "CUSTOM"
            authorizerId:
              Ref: "apiGatewayAuthorizer"
            resultTtlInSeconds: ${self:provider.resultTtlInSeconds}
          cors: true

That’s all folks :)
All you have to do now is write an amazing “Front End UI” that will allow your users to login to your App via Amazon Cognito and fetch data from APIs protected by the Custom Authorizer. A lot of people use “aws-amplify” for that purpose instead of writing their own custom modules.

Hope this walkthrough will save you a few hours of googling and reading the docs.