Building a Fargate-based container app with Cognito Authentication

Thumbnail

In this post I’m going to show you how to use Cognito User Authentication in combination with a Docker app running in Fargate behind an Application Load Balancer and we’re going to build all this with the Cloud Development Kit (CDK).

Why would you want to use this? You’re running your web application inside a docker container and don’t want to deal with user authentication.

Background

In a recent post I explained some of the authentication basics and protocols around Cognito. I recommend that you review this post if the terminology (e.g. identity provider, OIDC, JWT) is unfamiliar to you.

Explaining Docker is beyond the scope of the post, but I’m going to recap what ECS Fargate, Cognito and the Application Load Balancer (ALB) are.

ECS Fargate is a serverless way to run your Docker containers in AWS. It takes care of provisioning the underlying docker host and everything else to run a container. The only real drawback is, that your containers must be stateless (which they are in a perfect world anyways).

Cognito is one of the more complex services in that it is a low level abstraction of user management as a service. For our use case it’s sufficient to say, that Cognito User Pools allow you to manage users in your application. This means Cognito provides signup, password reset, authentication as well as login and logout workflows, which is cool. It also supports federating users from external identity providers such as your corporate AD, Google, Amazon or Facebook.

The Application Load Balancer (ALB) is one of the most famous AWS services. The ALB is a Layer 7 Load Balancer for HTTP and HTTPS traffic that integrates well with other AWS services such as ECS and Cognito. In addition to that, it allows routing based on HTTP paths, DNS names and much more.

Enough with the backstory, what are we going to build today?

Problem / Challenge

Suppose you have a web application running in a docker container and don’t want to manage the users yourself. In that case you can use Cognito in combination with the Application Load Balancer to handle that for you. In this post we’re going to dive into how to set that up with the CDK.

Architecture

We’re going to set up a Cognito User Pool with a custom domain and an user pool client to manage users and authentication. Then we’re going to set up a Docker Container running on Fargate behind an Application Load Balancer. Afterwards the Load Balancer will be configured to make users authenticate to Cognito before getting to our backend. This setup has the benefit that we can assume we have users that are already authenticated in our app and only have to worry about authorization (but not today).

Architecture

Note, that this setup also includes a VPC, I decided to build it this way to keep everything as self-contained as possible and because setting up VPCs is a piece of cake with the CDK. If you’d like to follow a long, you can find the code at Github, to use it yourself you need a public hosted zone in Route 53 for your DNS entries.

Prerequisites

In order to follow along, you need to have this set up:

  • The CDK
  • Docker
  • A public hosted zone in your account

Then you should:

  • Check out this repo
  • Navigate to the python-cognito-alb-fargate directory

Setting up the infrastructure step by step

Let’s now go through the stack step by step - we’re going to start by looking at the file cognito_fargate_demo/cognito_fargate_demo_stack.py.

First you should update these global variables with values that are appropriate for your account setup:

APP_DNS_NAME = "cognito-fargate-demo.mb-trc.de"
COGNITO_CUSTOM_DOMAIN = "alb-fargate-auth-demo-mbtrc"
HOSTED_ZONE_ID = "ZECQVEY17GSI4"
HOSTED_ZONE_NAME = "mb-trc.de"

The APP_DNS_NAME will be a DNS entry in your hosted zone that gets created by the stack. The value of COGNITO_CUSTOM_DOMAIN isn’t that important, it just has to be globally unique and DNS compliant. The parameters HOSTED_ZONE_ID and HOSTED_ZONE_NAME should be fairly self-explanatory, you can get both of those from the public hosted zone in your account.

Now the magic begins. We create a representation of the hosted zone inside the CDK which we can later reference. Afterwards we create a SSL/TLS certificate in the certificate manager, which the CDK automagically validates for us (this uses a custom resource under the hood that you had to write yourself before).

# Get the hosted Zone and create a certificate for our domain
hosted_zone = route53.HostedZone.from_hosted_zone_attributes(
    self,
    "HostedZone",
    hosted_zone_id=HOSTED_ZONE_ID,
    zone_name=HOSTED_ZONE_NAME
)

cert = certificatemanager.DnsValidatedCertificate(
    self,
    "Certificate",
    hosted_zone=hosted_zone,
    domain_name=APP_DNS_NAME
)

We continue with the basics by setting up a VPC across two AZs and a Fargate Cluster inside it. If you compare that to regular CloudFormation, this is ridiculously easy.

# Set up a new VPC
vpc = ec2.Vpc(
    self,
    "FargateDemoVpc",
    max_azs=2
)

# Set up an ECS Cluster for fargate
cluster = ecs.Cluster(
    self,
    "FargateCluster",
    vpc=vpc
)

Now we configure our Cognito Resources - yes, this is a little nasty. Unfortunately the higher level constructs aren’t very advanced (yet), which is why we need to get down to the underlying CloudFormation representations to edit some of the values.

# Configure the user pool and related entities for authentication
user_pool = cognito.UserPool(
    self,
    "UserPool",
    self_sign_up_enabled=True,
    user_pool_name="FargateDemoUserPool",

)

user_pool_custom_domain = cognito.CfnUserPoolDomain(
    self,
    "CustomDomain",
    domain=COGNITO_CUSTOM_DOMAIN,
    user_pool_id=user_pool.user_pool_id
)

user_pool_client = cognito.UserPoolClient(
    self,
    "AppClient",
    user_pool=user_pool,
    user_pool_client_name="AlbAuthentication",
    generate_secret=True
)

# Set the attributes on the user pool client that can't be updated via the construct
user_pool_client_cf: cognito.CfnUserPoolClient = user_pool_client.node.default_child
user_pool_client_cf.allowed_o_auth_flows = ["code"]
user_pool_client_cf.allowed_o_auth_scopes = ["openid"]
user_pool_client_cf.callback_ur_ls = [
    f"https://{APP_DNS_NAME}/oauth2/idpresponse",
    f"https://{APP_DNS_NAME}"
]
user_pool_client_cf.default_redirect_uri = f"https://{APP_DNS_NAME}/oauth2/idpresponse"
user_pool_client_cf.logout_ur_ls = [
    f"https://{APP_DNS_NAME}/logout",
    f"https://{APP_DNS_NAME}/"
]
user_pool_client_cf.supported_identity_providers = [
    # This is where you'd add external identity providers as well.
    "COGNITO"
]
user_pool_client_cf.allowed_o_auth_flows_user_pool_client = True

I know what you’re thinking - What the fish is that?.

Welcome to Cognito! - I told you it’s a low-level abstraction, didn’t I?

A couple of things are happening here:

  • We set up a user pool that works as a container for all our future users.
  • We set up a custom domain for that user pool, where the Login View will be hosted.
  • We configure a client for that user pool, that’s going to be used by the ALB to authenticate our users.
    • We set up that client to basically talk OIDC to our Load Balancer
    • We tell the client to let in only users from Cognito as we have no federated users from other identity providers here.
    • Most of these values I found out by trial and error as well as an assortment of blog posts - I’m not going to pretend the documentation was very helpful here.

Let’s now quickly focus on something more beautiful: Our Application container. If you’ve had a look at the repository you might have noticed the src directory, which contains a small web application that’s based on flask.

The CDK has a beautiful construct that builds a docker image and uploads it to an Elastic Container Registry it creates itself. This is so frictionless, I’m honestly amazed. You could obviously argue about if this is a good idea, but that’s a discussion for another time - for now this just works and it’s great.

# Define the Docker Image for our container (the CDK will do the build and push for us!)
docker_image = ecr_assets.DockerImageAsset(
    self,
    "JwtApp",
    directory=os.path.join(os.path.dirname(__file__), "..", "src")
)

Let’s continue with another magic trick. The CDK already has a pattern that deploys an ALB with an ECS Service + Fargate Container. All we need to do is use the ApplicationLoadBalancedFargateService pattern.

The way I have configured it, it does a lot of things for us with little code:

  • Set up the DNS Record for APP_DNS_NAME
  • Set up an Application Load Balancer
  • Configure that ALB with the SSL Certificate from above
  • Create an ECS Service
  • Create a ECS Task definition that references our docker image from above
    • It also sets an environment variable for the container that tells it on which port to accept traffic
    • and another environment variable that contains the logout link for the user pool client
  • Create a target group that points to the ECS service and register it with the load balancer
  • Set up security groups for the container and load balancer

    user_pool_domain = f"{user_pool_custom_domain.domain}.auth.{self.region}.amazoncognito.com"
    
    # Define the fargate service + ALB
    fargate_service = ecs_patterns.ApplicationLoadBalancedFargateService(
    self,
    "FargateService",
    cluster=cluster,
    certificate=cert,
    domain_name=f"{APP_DNS_NAME}",
    domain_zone=hosted_zone,
    task_image_options={
        "image": ecs.ContainerImage.from_docker_image_asset(docker_image),
        "environment": {
            "PORT": "80",
            "LOGOUT_URL": f"https://{user_pool_domain}/logout?"
                        + f"client_id={user_pool_client.user_pool_client_id}&"
                        + f"redirect_uri={ urllib.parse.quote(f'https://{APP_DNS_NAME}')}&"
                        + f"response_type=code&state=STATE&scope=openid"
        }
    }
    )
    

That’s pretty cool, right? Unfortunately it’s not a 100% match for our use case, so we have to edit that a little.

This is actually something I struggled with when setting this up, I had configured Cognito authentication and was getting weird HTTP 500 errors from the ALB. To enable the ALB to verify the tokens it gets from Cognito it needs access to the Cognito API.

I learned the hard way, that the pattern takes security seriously and only enables outbound traffic from the load balancer to the container security group. This resulted in the load balancer not being able to verify the tokens it was getting and responding with an HTTP 500. Fortunately the AWS Support (thanks Zeb!), ALB access logs and the documentation were able to help.

So we need to add another egress rule to the load balancer to allow it to talk HTTPS to the internet.

# Add an additional HTTPS egress rule to the Load Balancers security group to talk to Cognito
lb_security_group = fargate_service.load_balancer.connections.security_groups[0]

lb_security_group.add_egress_rule(
    peer=ec2.Peer.any_ipv4(),
    connection=ec2.Port(
        protocol=ec2.Protocol.TCP,
        string_representation="443",
        from_port=443,
        to_port=443
    ),
    description="Outbound HTTPS traffic to get to Cognito"
)

When testing new versions of the image I got quickly tired of waiting 5 minutes for in-flight requests to terminate before ECS would replace existing containers, so I decreased the timeout:

# Allow 10 seconds for in flight requests before termination, the default of 5 minutes is much too high.
fargate_service.target_group.set_attribute(key="deregistration_delay.timeout_seconds", value="10")

Last but not least we need to tell the load balancer to authenticate requests to cognito before forwarding them to our target group.

To do that, we have to once again drop down to the CloudFormation abstraction level and attach a CfnListenerRule to the CfnListener of our load balancer. What you can see in the code is, that the first action is an authenticate-cognito action which is configured to do exactly that against our user pool using our user pool client. Once that’s completed we forward the traffic to the target group we also extract from our fargate_service object. These rules only apply when the host-header condition is met, which has the effect, that it only applies when we access APP_DNS_NAME.

# Enable authentication on the Load Balancer
alb_listener: elb.CfnListener = fargate_service.listener.node.default_child

elb.CfnListenerRule(
    self,
    "AuthenticateRule",
    actions=[
        {
            "type": "authenticate-cognito",
            "authenticateCognitoConfig": elb.CfnListenerRule.AuthenticateCognitoConfigProperty(
                user_pool_arn=user_pool.user_pool_arn,
                user_pool_client_id=user_pool_client.user_pool_client_id,
                user_pool_domain=user_pool_custom_domain.domain
            ),
            "order": 1
        },
        {
            "type": "forward",
            "order": 10,
            "targetGroupArn": fargate_service.target_group.target_group_arn
        }
    ],
    conditions=[
        {
            "field": "host-header",
            "hostHeaderConfig": {
                "values": [
                    f"{APP_DNS_NAME}"
                ]
            }
        }
    ],
    # Reference the Listener ARN
    listener_arn=alb_listener.ref,
    priority= 1000
)

That’s it for the infrastructure. In about 180 lines of code we have defined:

  • A public SSL/TLS certificate for our domain.
  • A network spanning at least two physical datacenters with NAT gateways and internet access.
  • A highly available load balancer.
  • A user pool that can scale up to millions of users.
  • A registry for our docker image and a build process for it.
  • A management framework for our docker container (ECS Service) that monitors the container and keeps it alive.
  • A docker container that runs on serverless infrastructure.
  • Lots of firewall policies.

That’s pretty cool, right?

Authentication & the App

Let’s now have a look at what we built here. After navigating to https://APP_DNS_NAME in my case https://cognito-fargate-demo.mb-trc.de/ you should be redirected to the custom cognito login form. After signing up there you can log in (you might need to activate your Account in the Console).

Login Form

The Login Form can be customized with some CSS and logos as well, I chose not to do that for this demo. After logging in you should see this rather ugly website:

JWT Webapp

It displays the username with which you have just logged in as well as the payload and validity duration of the JSON Web Token the container received in the x-amzn-oidc-data header from the Load Balancer. The sub field uniquely identifies the current user and can for example be used as a database key. The username can in principle be changed. To learn more about the information the load balancer provides to the application I suggest you have a look at the documentation.

The Logout-link does exactly what you’d expect.

Let’s now have a look at the Authentication workflows.

Login Workflow

When a user accesses our website, they arrive at the application load balancer. Now there’s three cases to look at here:

  1. The user is already authenticated
  2. The user is not yet authenticated
  3. The user arrives with a token from Cognito

In the first case, the ALB adds the headers, I refered to above, and forwards the traffic to the Backend. The second case requires the user to authenticate first, so the ALB redirects them to Cognito. After they authenticate against Cognito or one of the other configured identity providers of the User Pool, Cognito creates a token and redirects the user back to the load balancer. The load balancer then checks that token and treats it as an authenticated request if the token is valid - furthermore it sets the AWSELBAuthSessionCookie-0 cookie which the browser sends in further requests. This cookie identifies an authenticated user.

Login Flow

Logout Workflow

Logging out is a workflow you need to partially implement yourself. Essentially you need to do two things:

  1. Expire the session cookie AWSELBAuthSessionCookie-0.
  2. Redirect the user to the Logout Endpoint of Cognito to end the “session” with Cognito (I put session in quotes, because this just tells cognito to not issue new tokes, it doesn’t actually invalidate existing tokens as that’s not possible).

I implemented this in my sample app as well (src/webapp.py)- it works like this:

  1. The user navigates to the /logout endpoint.
  2. The ALB adds the headers as mentioned above and hands the traffic over to the backend.
  3. Inside the container we expire the session cookie and redirect the user to the Logout-Endpoint.
  4. The Load Balancer hands that redirect back to the user.
  5. The browser follows the redirect to the Cognito endpoint which logs the user out.
  6. The Cognito endpoint redirects to the Login Page.

Logout Flow

Conclusion

In this blog post I’ve given you an example of how to integrate Cognito with the Application Load Balancer and Docker/Fargate and how to implement login and logout workflows.

If there are any questions or feedback, feel free to reach out to me on Twitter.

References