Protecting a Static Website with JWT and Lambda@Edge

2023-10-04
This post cover image
Voice provided by Amazon Polly

Adding authentication and authorization to a Single Page Webb App (SPA) using Amazon Cognito User Pool is very common and rather straight forward task. But what if you don't have a SPA? What if you have just a static website with static HTML files? How would you do it in that case?

This post all started when I needed to protect some static resources that was served from S3 over CloudFront. In the first setup I used a basic authentication with a simple username and password. After testing it out I just felt that there must be a better solution that allow me to have several users. So the idea of using Cognito User Pools, JWT, and Lambda@Edge was born.

So in this post we will explore exactly that, we will add authentication to a static website hosted using S3 and CloudFront.

CloudFront integration points

There are four different integration points for Lambda@Edge and you can only set one Lambda function as target for each. The integration is done on the cache behavior so if you have several cache behaviors you can setup integration with different functions for each of them. So how does each integration point work?

Viewer Request The Lambda function is invoked as soon as CloudFront receives the call from the client. This function will be invoked on every request.
Origin Request The Lambda function is invoked after the cache and before CloudFront calls the origin. The function will only be invoked if there is a cache miss.
Origin Response The Lambda function is invoked after CloudFront receives the response from the origin and before data is stored in the cache.
Viewer Response The Lambda function is invoked before CloudFront forwards the response to the client.

In this solution will we only use the Viewer Request integration point.

Image showing the integration points for Lambda@Edge.

Lambda@Edge limitations and gotchas

First of all, Lambda@Edge only support Python and NodeJS, there is no support for other runtimes. For Lambda Functions that run in response to Viewer Request and Response has a maximum memory size of 128MB and they can only run for 5 seconds. This is important to remember, we need to keep our functions short and efficient.

One large limitation is that Lambda@Edge functions doesn't support environment variables. That mean that in many cases we must add information directly in the code. However, we can actually call other AWS Services so it is possible to fetch environment configurations from SSM.

Cloudfront Functions

CloudFront Functions are light weight functions that run even closer to the user. Lambda@Edge run in the Regional Edge but CloudFront functions run already in the outer Edge location.

But there are some restrictions with CloudFront Functions which makes me rule them out in this scenario. They can only run for 1ms and they can't call any other AWS Service, so they would not be able to fetch any secrets from Secrets Manager or call Cognito to fetch JWT Tokens. To learn more about CloudFront Functions I would recommend reading this post

Solution Overview

The solution is made up of CloudFront, S3, Lambda@Edge and Cognito User Pool. It consists of 4 different sub-flows, Login, Authorization, Refresh, and Sign out, that will be invoked at different points. As usual there is an example setup using CloudFormation included, and you can find a version on GitHub. Below is an overview diagram of the entire solution.

We'll setup different cache behaviors that will be configured for different paths so we can use several different Lambda functions for each flow. What we don't want is to include all logic in one function, so we split it up and let CloudFront invoke different functions for different logic.

When the user try to access content a Lambda@Edge function will ensure that the user is authorized to view that content, if the user has not logged on there will be a redirect to Cognito User Pools hosted UI. Tokens will be automatically refreshed if there is a valid refresh token.

Image showing the overview.

Now that is not super clear, so let's break it down into the separate flows instead for more clarity.

Authorization checks

Every time the user try to access any page under example.com the browser will include any existing JWT tokens in the request. This will invoke our Lambda function responsible for authorization. This function will first of all check that there is a JWT token present, if not the login flow will be invoked. In case of an JWT token the signature will be validated, so the token has not been altered, if a valid token exists we'll check that it has not expired. In case of an expired token the refresh flow will be invoked. Final step is to validate the audience in the token to ensure it was issued for us. If everything is all good the page will be loaded from either the CloudFront cache or S3 bucket and returned to the user.

Image showing the authorization flow.

Login flow

When the user try to access example.com webpage through CloudFront, but has not been signed in yet, the user is redirected to the Cognito User Pool hosted UI. The user signs in with the given credentials and are redirected back to a example.com/signin this will now invoke the Lambda function associated with that path. The Lambda function then need to exchange the token, that we get from the user pool, for JWT tokens, ID, Access, and Refresh token. We get these by calling cognito with our client id and client secret. From the Lambda function we return a redirect back to example.com and at the same time instruct the browser to set cookies with the JWT tokens we just got.

Image showing the login flow.

Refreshing credentials

If we have a valid but expired token the access token needs to be refreshed. This happens when the browser is redirected to example.com/refresh which will invoke our refresh Lambda function. This flow looks like the signin flow in the sense that we will call Cognito and use the refresh token, client id and client secret and exchange these for a new access token. After a successful refresh the user is redirected back to example.com with instructions to the browser to set a new cookie with the new access token.

Image showing the refresh tokens flow.

Signing out

When the example.com/signout path is called the Lambda function will return an redirect with instructions to the browser to expire and delete the JWT tokens cookie.

Image showing the sign out flow.

Deploying the solution

When deploying the solution there is one important thing to remember, and that is that Lambda@Edge functions must be deployed in us-east-1 region, the same with the ACM certificate used by the CloudFront distribution. The User Pool and the actual CloudFront distribution can be deployed in any region. So in this setup we'll deploy the User Pool and CloudFront distribution in Stockholm (eu-north-1) to show how to do that.

Deploying the User Pool

First we'll deploy the User Pool and Client since information from that stack is needed in the Lambda functions. This will create the User Pool, the Client, and setup the domain for the hosted UI. This is deployed to eu-north-1. Since Lambda@Edge doesn't support environment variables we'll create SSM Parameters that can be used by our Lambda functions to get dynamic information.


AWSTemplateFormatVersion: "2010-09-09"
Transform: "AWS::Serverless-2016-10-31"
Description: Creates the User Pool and Client used for Authentication
Parameters:
Environment:
Type: String
Description: Environment type, dev, test, prod
AllowedValues:
- dev
- test
- prod
ApplicationName:
Type: String
Description: The application that owns this setup.
DomainName:
Type: String
Description: The domain name to use for cloudfront
HostedAuthDomainPrefix:
Type: String
Description: The domain prefix to use for the UserPool hosted UI <HostedAuthDomainPrefix>.auth.[region].amazoncognito.com
UserPoolSecretName:
Type: String
Description: The name that will be used in Secrets manager to store the User Pool Secret

Resources:
##########################################################################
# UserPool
##########################################################################
UserPool:
Type: AWS::Cognito::UserPool
Properties:
UsernameConfiguration:
CaseSensitive: false
AutoVerifiedAttributes:
- email
UserPoolName: !Sub ${Environment}-${ApplicationName}-user-pool
Schema:
- Name: email
AttributeDataType: String
Mutable: false
Required: true
- Name: name
AttributeDataType: String
Mutable: true
Required: true

UserPoolClient:
Type: AWS::Cognito::UserPoolClient
Properties:
UserPoolId: !Ref UserPool
GenerateSecret: True
AllowedOAuthFlowsUserPoolClient: true
CallbackURLs:
- !Sub https://${DomainName}/signin
AllowedOAuthFlows:
- code
- implicit
AllowedOAuthScopes:
- phone
- email
- openid
- profile
SupportedIdentityProviders:
- COGNITO

HostedUserPoolDomain:
Type: AWS::Cognito::UserPoolDomain
Properties:
Domain: !Ref HostedAuthDomainPrefix
UserPoolId: !Ref UserPool

UserPoolSecretNameParameter:
Type: AWS::SSM::Parameter
Properties:
Name: !Sub /${Environment}/serverlessAuth/userPoolSecretName
Type: String
Value: !Ref UserPoolSecretName
Description: SSM Parameter for the User Pool Secret Name
Tags:
Environment: !Ref Environment
ApplicationName: !Ref ApplicationName

UserPoolEndpointParameter:
Type: AWS::SSM::Parameter
Properties:
Name: !Sub /${Environment}/serverlessAuth/userPoolEndpoint
Type: String
Value: !Sub https://${HostedAuthDomainPrefix}.auth.${AWS::Region}.amazoncognito.com/oauth2/token
Description: SSM Parameter for the User Pool Endpoint
Tags:
Environment: !Ref Environment
ApplicationName: !Ref ApplicationName

UserPoolIdParameter:
Type: AWS::SSM::Parameter
Properties:
Name: !Sub /${Environment}/serverlessAuth/userPoolId
Type: String
Value: !Ref UserPool
Description: SSM Parameter for the User Pool Id
Tags:
Environment: !Ref Environment
ApplicationName: !Ref ApplicationName

UserPoolHostedUiParameter:
Type: AWS::SSM::Parameter
Properties:
Name: !Sub /${Environment}/serverlessAuth/userPoolHostedUi
Type: String
Value: !Sub https://${HostedAuthDomainPrefix}.auth.${AWS::Region}.amazoncognito.com/login?client_id=${UserPoolClient}&response_type=code&scope=email+openid+phone+profile&redirect_uri=https://${DomainName}/signin
Description: SSM Parameter for the User Pool Hosted UI
Tags:
Environment: !Ref Environment
ApplicationName: !Ref ApplicationName

ContentRootParameter:
Type: AWS::SSM::Parameter
Properties:
Name: !Sub /${Environment}/serverlessAuth/contentRoot
Type: String
Value: !Sub https://${DomainName}
Description: SSM Parameter for the Content Root
Tags:
Environment: !Ref Environment
ApplicationName: !Ref ApplicationName

Outputs:
CognitoUserPoolID:
Value: !Ref UserPool
Description: The UserPool ID
CognitoAppClientID:
Value: !Ref UserPoolClient
Description: The app client
CognitoUrl:
Description: The url
Value: !GetAtt UserPool.ProviderURL
CognitoHostedUI:
Value: !Sub https://${HostedAuthDomainPrefix}.auth.${AWS::Region}.amazoncognito.com/login?client_id=${UserPoolClient}&response_type=code&scope=email+openid+phone+profile&redirect_uri=https://${DomainName}/signin
Description: The hosted UI URL

Creating secrets

We need to create a secret in SecretsManager that contains the User Pool Client ID and Client Secret. Secrets should be created manually, or semi manually, to avoid having secrets stored in Git.

Navigate the the User Pool and the Application Client part to get the two secrets we need.

Image showing the location of the secrets.

Next we navigate to Secrets Manager and create a new Secret.

Image showing the creation of secret.

We create it as an Other type of Secret and set the values from the Use Pool.

Image showing the creation of secret.

The name of the secret is then needed in the next phase when we deploy the Lambda@Edge functions.

Deploy the functions

Now the it's time to deploy the Lambda functions in us-east-1. Since this template contains several functions and therefor is fairly large I'm only showing one function here, to get the full template check it out on GitHub. Since Lambda@Edge doesn't support Environment Variables we'll use the SSM Parameters created when deploying the User Pool template.


AWSTemplateFormatVersion: "2010-09-09"
Transform: "AWS::Serverless-2016-10-31"
Description: Creates Lambda@Edge functions and SSL certificate
Parameters:
Environment:
Type: String
Description: Environment type, dev, test, prod
AllowedValues:
- dev
- test
- prod
ApplicationName:
Type: String
Description: The application that owns this setup.
DomainName:
Type: String
Description: The domain name to use for cloudfront
HostedZoneId:
Type: String
Description: The id for the Route53 hosted zone
SecretArn:
Type: String
Description: The ARN for the user Pool Client Secret in Secrets manager
SsmParametersArn:
Type: String
Description: The ARN for the parameters in SSM

Globals:
Function:
Timeout: 5
MemorySize: 128
Runtime: python3.9

Resources:
##########################################################################
# Domain Certificate
##########################################################################
SSLCertificate:
Type: AWS::CertificateManager::Certificate
Properties:
DomainName: !Ref DomainName
DomainValidationOptions:
- DomainName: !Ref DomainName
HostedZoneId: !Ref HostedZoneId
ValidationMethod: DNS

##########################################################################
# Lambda@Edge Functions
##########################################################################
AuthorizeFunction:
Type: AWS::Serverless::Function
Properties:
AutoPublishAlias: "true"
CodeUri: ./EdgeLambda/Authorize
Handler: auth.handler
AssumeRolePolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Principal:
Service:
- lambda.amazonaws.com
- edgelambda.amazonaws.com
Action:
- sts:AssumeRole
Policies:
- AWSSecretsManagerGetSecretValuePolicy:
SecretArn: !Ref SecretArn
- Version: "2012-10-17"
Statement:
Action:
- ssm:GetParameter
- ssm:GetParameters
- ssm:GetParametersByPath
Effect: Allow
Resource: !Ref SsmParametersArn
- Version: "2012-10-17"
Statement:
Action:
- lambda:GetFunction
Effect: Allow
Resource: "*"

In this step we also create a SSL Certificate that will be used by our CloudFront distribution, since this SSL certificate also need to live in us-east-1.

Deploy the CloudFront distribution

Now it's time to deploy the CloudFront distribution, and here is where we put everything together. We'll be creating several CacheBehaviors so we can properly invoke the different Lambda functions that will handle the sign-in, sign-out, refresh, and default flows. Since a CacheBehavior need to be attached to an origin we will actually create a dummy origin. This origin will never be called as our Lambda functions are integrated on the viewer request hook.


AWSTemplateFormatVersion: "2010-09-09"
Transform: "AWS::Serverless-2016-10-31"
Description: Creates the infrastructure for hosting the static content
Parameters:
Environment:
Type: String
Description: Environment type, dev, test, prod
AllowedValues:
- dev
- test
- prod
ApplicationName:
Type: String
Description: The application that owns this setup.
BucketNameSuffix:
Type: String
Description: The last part of the bucket name. Full name will be {ApplicationName}-{Environment}-{BucketNameSuffix}
DomainName:
Type: String
Description: The domain name to use for cloudfront
HostedZoneId:
Type: String
Description: The id for the Route53 hosted zone
SSLCertificateArn:
Type: String
Description: The ARN to the SSL certificate that exists in ACM in us-east-1
SignInFunctionArn:
Type: String
Description: ARN to the Lambda@Edge Function handling Sign In
AuthorizeFunctionArn:
Type: String
Description: ARN to the Lambda@Edge Function handling Authorize
RefreshFunctionArn:
Type: String
Description: ARN to the Lambda@Edge Function handling Refresh
SignOutFunctionArn:
Type: String
Description: ARN to the Lambda@Edge Function handling Sign Out
IndexPathFunctionArn:
Type: String
Description: ARN to the Lambda@Edge Function handling Index Path changes

Resources:
##########################################################################
# Content Bucket
##########################################################################
ContentBucket:
Type: AWS::S3::Bucket
Properties:
BucketEncryption:
ServerSideEncryptionConfiguration:
- ServerSideEncryptionByDefault:
SSEAlgorithm: AES256
BucketName: !Sub ${Environment}-${ApplicationName}-${BucketNameSuffix}
Tags:
- Key: Application
Value: !Ref ApplicationName
ContentBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref ContentBucket
PolicyDocument:
Statement:
- Action: s3:GetObject
Effect: Allow
Resource: !Sub ${ContentBucket.Arn}/*
Principal:
Service: cloudfront.amazonaws.com
Condition:
StringEquals:
AWS:SourceArn: !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/${CloudFrontDistribution}

##########################################################################
# CloudFront Distribution
##########################################################################
CloudFrontOAC:
Type: AWS::CloudFront::OriginAccessControl
Properties:
OriginAccessControlConfig:
Description: !Sub OAC for ${ApplicationName}
Name: !Sub ${Environment}-${ApplicationName}-oac
OriginAccessControlOriginType: s3
SigningBehavior: always
SigningProtocol: sigv4

CloudFrontDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Aliases:
- !Ref DomainName
Comment: !Sub "Distribution for the ${ApplicationName} ${Environment}"
CustomErrorResponses:
- ErrorCode: 403
ResponseCode: 200
ResponsePagePath: "/404.html"
DefaultCacheBehavior:
AllowedMethods:
- "GET"
- "HEAD"
- "OPTIONS"
Compress: False
CachePolicyId: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad #Managed Cache Policy 'CachingDisabled'
LambdaFunctionAssociations:
- EventType: viewer-request
LambdaFunctionARN: !Ref AuthorizeFunctionArn
- EventType: origin-request
LambdaFunctionARN: !Ref IndexPathFunctionArn
TargetOriginId: !Sub ${Environment}-${ApplicationName}-dynamic-s3
ViewerProtocolPolicy: redirect-to-https
CacheBehaviors:
- PathPattern: /signin
TargetOriginId: lambda-auth-origin
ViewerProtocolPolicy: redirect-to-https
ForwardedValues:
QueryString: true
LambdaFunctionAssociations:
- EventType: viewer-request
LambdaFunctionARN: !Ref SignInFunctionArn
- PathPattern: /refresh
TargetOriginId: lambda-auth-origin
ViewerProtocolPolicy: redirect-to-https
ForwardedValues:
QueryString: true
LambdaFunctionAssociations:
- EventType: viewer-request
LambdaFunctionARN: !Ref RefreshFunctionArn
- PathPattern: /signout
TargetOriginId: lambda-auth-origin
ViewerProtocolPolicy: redirect-to-https
ForwardedValues:
QueryString: true
LambdaFunctionAssociations:
- EventType: viewer-request
LambdaFunctionARN: !Ref SignOutFunctionArn
DefaultRootObject: index.html
Enabled: True
Origins:
- DomainName: !Sub ${Environment}-${ApplicationName}-${BucketNameSuffix}.s3.amazonaws.com
Id: !Sub ${Environment}-${ApplicationName}-dynamic-s3
OriginAccessControlId: !GetAtt CloudFrontOAC.Id
S3OriginConfig:
OriginAccessIdentity: ""
- DomainName: !Sub handle-lambda-auth.${DomainName}
Id: lambda-auth-origin
CustomOriginConfig:
OriginProtocolPolicy: match-viewer
PriceClass: PriceClass_100
ViewerCertificate:
AcmCertificateArn: !Ref SSLCertificateArn
SslSupportMethod: sni-only
Tags:
- Key: Application
Value: !Ref ApplicationName
- Key: Name
Value: !Sub ${ApplicationName}-${Environment}

##########################################################################
# Route53
##########################################################################
Route53Record:
Type: AWS::Route53::RecordSet
Properties:
AliasTarget:
DNSName: !GetAtt CloudFrontDistribution.DomainName
HostedZoneId: Z2FDTNDATAQYW2 # CloudFront static value
Comment: !Sub "Record for ${ApplicationName}-${Environment} cloudfront distribution"
HostedZoneId: !Ref HostedZoneId
Name: !Sub ${DomainName}.
Type: A

Test it all

To test the solution we start by creating a user in the User Pool. Image showing create user.

And we upload some content to the S3 bucket. When trying to access the content we should be redirected to the Cognito Hosted UI for login. Image showing sign in.

After a successful login we should be redirected to the content root of everything. Now this is one area that does need improvement, since if try to access https://example.com/my-cool-image.png we like to end up on that image after login and not on https://example.com, this can be accomplished by using the uri that is found in the event

After being logged in we should be able to view for example https://example.com/my-cool-image.png

Debugging

One thing to keep in mind when working with Lambda@Edge is that logs doesn't end up in the us-east-1 region, even if the functions are deployed there. Logs end up in the edge location that the function is run in, and that depends on what region is closest to the viewer. Sometimes the closest region is not the one you think, for example for me logs often end up in Frankfurt or London region even if Stockholm region feels closer.

Github

A full version with all code is available on GitHub.

Final Words

I think this is a very interesting project that is showing the power of Lambda@Edge and what can be done with compute at the edge. This is a solution that I will be using in several places where protection of static content in CloudFront is needed.

Don't forget to follow me on LinkedIn and Twitter for more content, and read rest of my Blogs