The service appears limited within the console, however, under the hood its has some features not immediately apparent.

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 "$"
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 .
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
- "$.$"
- 
CustomDomainConfig:
CertificateArn: !FindInMap [AccountConfig, !Ref 'AWS::AccountId', DefaultCert]
# END - user pool domains

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

userPoolId:
Export:
Name: !Sub '$-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 "$"  \
--template-file cognito-userpool.yaml \
--s3-bucket "$" \
--s3-prefix "cloudformation/$/package/$(date +%s)" \
--output-template-file cognito-userpool-packaged-template.yaml

aws cloudformation deploy --template-file cognito-userpool-packaged-template.yaml \
--region "$"  \
--stack-name "$" \
--no-fail-on-empty-changeset \
--s3-bucket "$" \
--s3-prefix "cloudformation/$/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)

2.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 '$'

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=$"

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

aws cloudformation deploy --template-file app-client-packaged-template.yaml \
--region "$"  \
--stack-name "$" \
--no-fail-on-empty-changeset \
--parameter-overrides $ \
--s3-bucket "$" \
--s3-prefix "cloudformation/$/template/$(date +%s)" \
--capabilities CAPABILITY_IAM

3.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 $ \
--username $ \
--user-attributes \
Name=email,Value=$ \
Name=given_name,Value=$ \
Name=family_name,Value=$ \
Name=preferred_username,Value=$ \
Name=custom:upload_folder,Value=$ \
--region ap-southeast-2

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

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

aws cognito-idp admin-add-user-to-group \
--user-pool-id $ \
--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/<span class="hljs-comment">#id_token</span><span class="hljs-comment">=</span><span class="hljs-comment">eyJraWQiOiJOcmp2S2RlV3hjVXNySFhjcUdUVXJ5OVB2N1RET2Vzek9HcWlERWs3czNNPSIsImFsZyI6IlJTMjU2In0.eyJhdF9oYXNoIjoiZ0p0RHBQOVE0S1dlWHFMcGF6UHRxUSIsInN1YiI6ImM2NDM2ZjRiLTEwMmUtNDNjNS1hMzFiLTM5YjM4MTgwZDg5MyIsImNvZ25pdG86Z3JvdXBzIjpbInVwbG9hZGVyIl0sImlzcyI6Imh0dHBzOlwvXC9jb2duaXRvLWlkcC5hcC1zb3V0aGVhc3QtMi5hbWF6b25hd3MuY29tXC9hcC1zb3V0aGVhc3QtMl9xcjdHQTZzNVQiLCJjb2duaXRvOnVzZXJuYW1lIjoiYzY0MzZmNGItMTAyZS00M2M1LWEzMWItMzliMzgxODBkODkzIiwicHJlZmVycmVkX3VzZXJuYW1lIjoibGVvbi5rb2xjaGluc2t5QG1hbnRhbHVzLmNvbSIsImdpdmVuX25hbWUiOiJMZW9uIiwiYXVkIjoiM3ZmODB1ZnRmaWVnaXFkMWQ4aWFpaGZicTUiLCJldmVudF9pZCI6ImQwMzY3ZGQ5LTZjNzItNDVhYy05Njc1LWY1ZTM3NWM4YzAwYSIsInRva2VuX3VzZSI6ImlkIiwiYXV0aF90aW1lIjoxNTkwMTI0ODE4LCJjdXN0b206dXBsb2FkX2ZvbGRlciI6Imxlb25rb2xjaGluc2t5LXVwbG9hZGZvbGRlciIsImV4cCI6MTU5MDEyODQxOCwiaWF0IjoxNTkwMTI0ODE4LCJmYW1pbHlfbmFtZSI6IktvbGNoaW5za3kiLCJlbWFpbCI6Imxlb24ua29sY2hpbnNreUBtYW50YWx1cy5jb20ifQ.EGJODxijBO03Y2tpffp8fmSLYVJiNkRhHW6rz4Yy4cC2hGFQVbiWM0nEAUKU6VSsC8zwvp-uYZPc1RA_qLWQ1kfSr-gpRI2wrx0FPPrhtuWt3qw2mpVMUbIxqgrKDKi6CeQOgGZtoN9GcKdEbEDViqo9dQMiqfgwglzw7X-VmGqAEel4eraKsjkP-Stqmdimd-TRsOBudj1QySI0MvgioywYRNFNnpRNOhB-_nTwO-vm9fxO8T7e767TQUP9QDRWnC6iNFVbh7CmYNjsLBHzV45nlFg60tJTSh01CD5oN2P6UNIJqxjEKasb_9Ek-A8bENH_wcvbLCUqlXMd0x3vYw&access_token</span><span class="hljs-comment">=</span><span class="hljs-comment">eyJraWQiOiJBbHV3YWVEU1wvWitMT3BtVEVzOXA0WDZcL2Iyb3cyYXB1OGJBVzFEVk1kaFE9IiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiJjNjQzNmY0Yi0xMDJlLTQzYzUtYTMxYi0zOWIzODE4MGQ4OTMiLCJjb2duaXRvOmdyb3VwcyI6WyJ1cGxvYWRlciJdLCJpc3MiOiJodHRwczpcL1wvY29nbml0by1pZHAuYXAtc291dGhlYXN0LTIuYW1hem9uYXdzLmNvbVwvYXAtc291dGhlYXN0LTJfcXI3R0E2czVUIiwidmVyc2lvbiI6MiwiY2xpZW50X2lkIjoiM3ZmODB1ZnRmaWVnaXFkMWQ4aWFpaGZicTUiLCJldmVudF9pZCI6ImQwMzY3ZGQ5LTZjNzItNDVhYy05Njc1LWY1ZTM3NWM4YzAwYSIsInRva2VuX3VzZSI6ImFjY2VzcyIsInNjb3BlIjoicGhvbmUgb3BlbmlkIHByb2ZpbGUgZW1haWwiLCJhdXRoX3RpbWUiOjE1OTAxMjQ4MTgsImV4cCI6MTU5MDEyODQxOCwiaWF0IjoxNTkwMTI0ODE4LCJqdGkiOiI3ZjVlMmYyZS00NWY0LTQ0NWYtYWQ1Ni0yYzU1MDk4MGI1NzciLCJ1c2VybmFtZSI6ImM2NDM2ZjRiLTEwMmUtNDNjNS1hMzFiLTM5YjM4MTgwZDg5MyJ9.Cu-rBjkFXn9TvXNYGjEuK2_QSndvPQroldeD4UI2VC8mDk2_38CffpsTdaVzFt1SXEoNGDxv8x28EuP3BjPI20S69ctLWA1Luod7zhvN9tGD6xZQMYmjm7Oa5gIe3nbFEFScWbbebYfNnAnEOdfdQ6djzNDtuQRo5h2eagjwyfvqcCYt7DD0QuSf-goLWT0AXS-ahrbajNSLUyoTZT18HrN8eRzdNfVqjzOSKfGcSyYxpE9LYtnLYR0Lj1HpKDSFFYrxp5mEE_E77fvUnbbIjuS1EkM618d8NoDg-R8mzS3n6lhTmmGSW55uiORFFJvXfXWlJ5oAvA2VRkCb1b0LQw&expires_in</span><span class="hljs-comment">=</span><span class="hljs-comment">3600&token_type</span><span class="hljs-comment">=</span><span class="hljs-comment">Bearer</span>

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

access_token

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

4.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
├── <span class="hljs-keyword">package</span>.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 = ; 
const pool_region = 'ap-southeast-2';

/**
  * Returns an IAM policy document for a given user and resource.
  *
  * @method buildIAMPolicy
  * @param  userId - user id
  * @param  effect  - Allow / Deny
  * @param  resource - resource ARN
  * @param  context - response context
  * @returns  policyDocument
  */
 const buildIAMPolicy = (userId, effect, resource, context) => ;
//
// Reusable Authorizer function, set on `authorizer` field in serverless.yml
module.exports.authorize = (event, context, cb) => ;

package.json

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: $
default_app_access_group: uploader
app_access_group: $

tags:
team: leons
platform: it
service: $
environment: $
stackTags:  
team: leons
platform: it
service: $
environment: $

provider:
name: aws
runtime: nodejs10.x
stackName: $-$-$
memorySize: 128
stage: $
deploymentBucket: deployment-templates-bucket-private
timeout: 30
logRetentionInDays: ${self:custom.logRetention.$, 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: $-$-$-auth
role: myCustRole
cors: true
environment:
APP_ACCESS_GROUP: $
USER_POOL_ID: ${self:custom.UserPoolId.$}
CLIENT_ID: ${self:custom.ClientId.$}

resources:
Resources:
# Custom role RoleName is tied to "appname" due to the way serverless framework constructs "rolename" - $-$-[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/$/
RoleName: $-$-$-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 $ –appname $ –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:

<span class="hljs-constant">AuthLambdaFunctionQualifiedArn</span><span class="hljs-symbol">:</span> <span class="hljs-symbol">arn:</span><span class="hljs-symbol">aws:</span><span class="hljs-symbol">lambda:</span>ap-southeast-<span class="hljs-number">2</span><span class="hljs-symbol">:</span><span class="hljs-number">123456789123</span><span class="hljs-symbol">:function</span><span class="hljs-symbol">:authlambda-blog-dev-testapp-auth</span>

5.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
    ├── <span class="hljs-keyword">private</span>.js
    └── <span class="hljs-keyword">public</span>.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 = ;

app.get('/public', cors(corsOptions), function (req, res) );

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());
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 = 

app.use('/private',function(req, res, next));


app.get('/private', cors(corsOptions), function (req, res) );


module.exports.handler = serverless(app);

package.json

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: $
deploymentBucket: deployment-templates-bucket-private
timeout: 30
logRetentionInDays: $
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: $
environment: $
stackTags:  
team: leons
platform: it
service: $
environment: $

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: $
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:#:lambda:path/2015-03-31/functions/arn:aws:lambda:#: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 $):

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

We’ve got our endpoints now:

endpoint<span class="hljs-variable">s:</span>
  GET - http<span class="hljs-variable">s:</span>//yijhzu5fig.<span class="hljs-keyword">execute</span>-api.ap-southeast-<span class="hljs-number">2</span>.amazonaws.<span class="hljs-keyword">com</span>/dev/public
  GET - http<span class="hljs-variable">s:</span>//yijhzu5fig.<span class="hljs-keyword">execute</span>-api.ap-southeast-<span class="hljs-number">2</span>.amazonaws.<span class="hljs-keyword">com</span>/dev/private

Let’s check those out:

$ curl https://yijhzu5fig.execute-api.ap-southeast-2.amazonaws.com/dev/public


$ curl https://yijhzu5fig.execute-api.ap-southeast-2.amazonaws.com/dev/private

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

Much better 🙂

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

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):

<span class="hljs-keyword">var</span> whitelist = [
    <span class="hljs-symbol">'http</span>:<span class="hljs-comment">//localhost:3000',</span>
    <span class="hljs-symbol">'http</span>:<span class="hljs-comment">//localhost'</span>
]

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 = ;

// Public API
module.exports.publicEndpoint = (event, context, cb) => ;

// Private API
module.exports.privateEndpoint = (event, context, cb) => ;

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: $
          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.