Protecting a Static Website with JWT and Lambda@Edge

jimmydqv

Jimmy Dahlqvist

Posted on October 4, 2023

Protecting a Static Website with JWT and Lambda@Edge

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


Enter fullscreen mode Exit fullscreen mode

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: "*"

Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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

💖 💪 🙅 🚩
jimmydqv
Jimmy Dahlqvist

Posted on October 4, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related