Application-load-balancer

29 August 2023

Authenticating your users to your development and internal applications allows you to restrict public access to them and secure your infrastructure. This article explores an authentication implementation with an Application Load Balancer (ALB) and AWS Cognito, offering a seamless developer experience.

Why should you authenticate your users to your applications?

It is a common occurrence to come across development or internal applications left open for public access. It poses a problem for multiple aspects, including business, SEO, and security.

From a security angle, exposing your development and internal applications to the public increases the risk of unauthorized access and potential security breaches. These applications may contain sensitive data, code vulnerabilities, or unfinished features that could be exploited by malicious actors. This can lead to data breaches, compromising the privacy and integrity of your organization and its customers. It's a significant risk since most hacked companies are compromised through development interfaces.

From a business perspective, publicly accessible development applications may showcase unfinished features, broken functionalities, or inconsistent design, leading to a negative image of your business. They are typically not optimized for user experience or performance. It can damage your brand's reputation and erode customer trust.

Publicly accessible development applications can be indexed by search engines, leading to duplicate content when your development and production environments are similar. This can harm your organization's SEO efforts, as unnecessary content may be indexed.

Overall, restricting public access to development and internal applications is of utmost importance. Authenticating your users is a best practice in achieving this objective.

What are the methods to implement authentication?

In the realm of authentication, two key levels come into play: network-level authentication and application-level authentication. Each level addresses distinct aspects of the authentication process and contributes to overall security. By combining both, organizations can establish a layered approach to authentication, thereby applying the defense-in-depth strategy.

Here are different methods to implement authentication at the network level and/or application level.

VPN

A Virtual Private Network (VPN) provides a secure and encrypted connection by establishing a private network over a public network infrastructure. It focuses on network-level authentication.

  • Authentication Level: Network level
  • Pros: Provides secure remote access, encrypts network traffic, and enables network segmentation for enhanced security.
  • Cons: Requires complex installation and configuration.

IP Whitelisting

IP Whitelisting operates at the network level by restricting access based on trusted IP addresses. It allows only specific IP addresses or ranges to access the system, effectively reducing the risk of unauthorized access attempts.

  • Authentication Level: Network level
  • Pros: Offers enhanced security by restricting access to trusted IP addresses, reducing the risk of unauthorized access attempts.
  • Cons: Poses usability issues when users don't have fixed or predictable IP addresses, for example for remote workers, or users connecting from different locations.

Bastion Host

A Bastion Host is a highly secured computer or server that is specifically designed to provide access to a private network from an external or less secure network, such as the Internet. It acts as a single entry point to the network, and its primary purpose is to enhance security by minimizing direct access to the internal systems.

It operates at the network level by controlling access to the network infrastructure. However, once authenticated at the network level, users can also proceed to authenticate at the application level to access specific applications or resources within the network.

  • Authentication Level: Network level + application level
  • Pros: Enhances security through a secure gateway, isolation of the authentication infrastructure, and granular access control.
  • Cons: Requires management expertise, and potential single point of failure.

Here is an article about setting up an SSH bastion host on the cloud using sshuttle.

OpenID Connect (OIDC)

OpenID Connect (OIDC) is an authentication layer built on top of OAuth 2.0., which is primarily focused on application-level authentication and identity federation. It provides a standard way for clients to verify the identity of end-users. Users can authenticate using their existing accounts from trusted identity providers, such as social media platforms or enterprise identity providers.

  • Authentication Level: Application level
  • Pros: Standardized protocol with interoperability and SSO capabilities, supports identity federation, allows for MFA, and simplifies the authentication process for users.
  • Cons: Dependency on external identity providers

It can be used to implement centralized authentication, allowing your users to authenticate to multiple applications using the same identity provider. It is a powerful tool for implementing robust security measures and streamlining user access. Let’s now focus on how to implement centralized authentication using an Application Load Balancer via OIDC.

How to implement a centralized authentication using an Application Load Balancer?

Application Load Balancer features

An Application Load Balancer (ALB) is a type of load balancer provided by Amazon Web Services (AWS) that is designed to route traffic to multiple targets such as EC2 instances, containers, and IP addresses. ALBs can also provide various features such as TLS termination, content-based routing, and centralized authentication.

Now, let's dive deeper into the authentication capabilities offered by AWS Application Load Balancers. First, let’s define target groups and listeners.

  • A target group is a logical group of targets that you register with an Application Load Balancer. It represents a set of instances that the Application Load Balancer can forward traffic to based on the configured rules. These rules are configured within a listener.
  • A listener is responsible for processing incoming connections and forwarding them to the appropriate target group. Listener rules are defined to determine which actions the Application Load Balancer should perform based on conditions. Supported action types include authentication and forwarding. It can be used with host-based or path-based conditions, which allows for more granular control over traffic routing.

AWS Application Load Balancers can handle OIDC by configuring a listener rule with an authenticate-oidc action type, which is supported only with HTTPS listeners. You will find an example of implementation of this action below. You can also find the documentation of this action in the AWS documentation.

AWS Cognito

Cognito is a powerful and fully managed service on AWS that enables developers to easily add user sign-up and sign-in to mobile and web apps. With AWS Cognito, developers can build scalable and secure user directories that can handle hundreds of millions of users. In another article, we explored AWS Cognito, explaining its functioning as well as discussing attacks and mitigations associated with it.

To quickly recap the functioning, AWS Cognito service is divided into two entities: the user pool and the identity pool. The user pool on AWS Cognito allows you to create users that will authenticate to your application. Those users can either be locally created on AWS Cognito, but can also come from another identity provider like Google, Microsoft, or even another OIDC Provider. On the other hand, the identity pool is used to authorize an external identity to access AWS Resources.

In our case, we will use AWS Cognito with a user pool to quickly have an OIDC Provider.

Cognito authentication handling

Here is the authentification flow:

authentificationflow

Authentication is handled by the Application Load Balancer by using cookies on the client side. Every new request goes through steps 1 through 9 to authenticate a new user by creating AWSELB cookies, then step 10 to forward the user to the target. If the Application Load Balancer already authenticated a user, requests already have AWSELB cookies on the client side to authenticate them, and they directly go through step 10.

Once the Application Load Balancer successfully authenticates a user, it forwards the user claims received from AWS Cognito to the designated target. The server-side receives new headers added by the Application Load Balancer:

  • x-amzn-oidc-accesstoken: a JWT containing the access token retrieved from the issuer.
  • x-amzn-oidc-data: a JWT containing the user claim. You can get the email or phone number.
  • x-amzn-oidc-identity: the user ID in the pool.

After decoding x-amzn-oidc-data , we get:

{
  "sub": "a63c5051-6ddf-4742-8d63-1ef394ec7eb6",
  "email": "email@example.org",
  "username": "my_username",
  "exp": 1686153020,
  "iss": "https://cognito-idp.eu-west-3.amazonaws.com/eu-west-3_xxxxxxxxx"
}

Meanwhile, we get the following data after decoding x-amzn-oidc-accesstoken:


{
  "sub": "a63c5051-6ddf-4742-8d63-1ef394ec7eb6",
  "email": "email@example.org",
  "username": "my_username",
  "exp": 1686153020,
  "iss": "https://cognito-idp.eu-west-3.amazonaws.com/eu-west-3_xxxxxxxxx"
}

This means you can easily get the identity in your application and extra-data-like groups where you can map it in your application.

Proof of Concept

We will build the following architecture as a proof of concept. The source code is available on GitHub.


proofofconcept

We built an EKS cluster that has multiple applications. Those applications are exposed outside the cluster using NGINX as the ingress-controller. The traffic is routed to the ingress controller from a private Network Load Balancer that is created using a LoadBalancer Kubernetes service. Finally, we connect an Application Load Balancer integrated with AWS Cognito.

You might ask, what is the point of the Network Load Balancer? The reason is to separate the components deployed with Helm and the components deployed with Terraform: we deploy all the Kubernetes manifests and the NLB using Helm, and the rest with Terraform. It would have been possible to directly connect an ALB to ingress-nginx using the approach described in the AWS documentation.

We briefly explored this solution but decided not to implement it for several reasons. We were unsure if it would be possible to fully configure the ALB using Helm. For example, adding a listener connected with Cognito might not have been feasible. In such a case, we would have ended up with an ALB managed by both Helm and Terraform, resulting in a less clean setup. Plus, implementing this solution would have required adding a plugin to Kubernetes.

To focus on some parts of the code, here are the interesting parts of the ALB connection with Cognito. We configured a listener on the Application Load Balancer with a default action in two steps: authenticating the user with AWS Cognito and then forwarding the request to the target group.

As you can see, you could easily replace AWS Cognito with another OIDC Provider, such as Auth0, by configuring the authenticate_oidc block with the desired OIDC Provider information.

resource "aws_lb" "this" {
  name               = "my-alb"
  load_balancer_type = "application"
	# ...
}

resource "aws_lb_listener" "https" {

	# First, we authenticate the user
  default_action {
    type = "authenticate-oidc"

    authenticate_oidc {
      authorization_endpoint = "https://${var.cognito_user_pool_domain}.auth.${var.cognito_region}.amazoncognito.com/oauth2/authorize"
      client_id              = var.cognito_user_pool_client_id
      client_secret          = var.cognito_user_pool_client_secret
      issuer                 = "https://${var.cognito_user_pool_endpoint}"
      token_endpoint         = "https://${var.cognito_user_pool_domain}.auth.${var.cognito_region}.amazoncognito.com/oauth2/token"
      user_info_endpoint     = "https://${var.cognito_user_pool_domain}.auth.${var.cognito_region}.amazoncognito.com/oauth2/userInfo"
    }
  }

	# Then, we forward the request to the target group
  default_action {
    type = "forward"
		target_group_arn = aws_lb_target_group.this.arn
  }

	# ...
}

# ...

This gives us the following result in the AWS console:

awsconsole

If we try to reach a service behind the AWS Application Load Balancer, we are correctly redirected to an AWS Cognito login form.


loginform

If you have multiple services, some of which should be publicly accessible without authentication, it is straightforward to whitelist them so they do not use AWS Cognito authentication. In our example, we have created an application with the domain name "without-cognito.padok.school".

We have configured an additional listener rule so that if a request has the desired domain name as the host header, it will be directly forwarded to the target group without going through Cognito authentication.

You can add all the desired conditions in the condition block to filter your services.

resource "aws_lb_listener_rule" "auth_oidc" {
  listener_arn = aws_lb_listener.https.arn

	# If host header = "without-cognito.padok.school"
  condition {
    host_header {
      values = ["without-cognito.padok.school"]
    }
  }

	# We forward the request to the target group
  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.this.arn
  }
}

Limitations

The authentication method described in the Proof of Concept comes with some limitations.

  • AWS Cognito does not currently support wildcard domains. This means that if you have multiple subdomains or dynamic domains that you want to authenticate with AWS Cognito, you will need to configure each domain separately. This can result in additional setup and maintenance overhead, especially if you have a large number of domains or subdomains.
  • If your applications are not OIDC-compliant, your users may need to log in twice, first with AWS Cognito, and then with the application. For example, this is the case for ArgoCD.

To go further: implement one-step authentication with OIDC-compliant applications

With the precedent configuration, your users need to log in twice: first with AWS Cognito, then with the application. We tried to configure AWS Cognito with an OIDC-compliant application to achieve a one-step authentication. The goal was to allow users to login into an application by logging into AWS Cognito only.

We built a proof of concept with Grafana. Grafana is an open-source analytics and monitoring application that provides visualizations and insights into various data sources. Grafana is OIDC-compliant, allowing seamless integration with identity providers using the OpenID Connect protocol.

Here is how you can deploy a Grafana application that uses the JWT created by AWS Cognito for authentication. You can configure this either in the grafana.ini file or by setting environment variables as follows:

image:
  repository: grafana/grafana
  env: 
    - name: GF_AUTH_JWT_ENABLED
      value: "true"   
    - name: GF_AUTH_JWT_HEADER_NAME
      value: "x-amzn-oidc-accesstoken"
    - name: GF_AUTH_JWT_USERNAME_CLAIM
      value: "username"
    - name: GF_AUTH_JWT_EMAIL_CLAIM
      value: "email"
    - name: GF_AUTH_JWT_JWK_SET_URL
      value: "https://cognito-idp.eu-west-3.amazonaws.com/eu-west-xxxxxx/.well-known/jwks.json"
    - name: GT_AUTH_JWT_CACHE_TTL
      value: "60m" 
    - name: GF_AUTH_JWT_EXPECT_CLAIMS
      value: '{"iss": "https://cognito-idp.eu-west-3.amazonaws.com/eu-west-xxxxxx"}'
    - name: GF_AUTH_SIGNOUT_REDIRECT_URL
      value: "https://poc-eks-alb-oidc.auth.eu-west-3.amazoncognito.com/logout?client_id=xxxxxx&logout_uri=https://grafana.padok.school/login"

You can see that GF_AUTH_JWT_HEADER_NAME indicates Grafana to use the x-amzn-oidc-accesstoken header. As explained in the previous part, this header is forwarded by AWS Cognito and authenticates the user, identified by his email.

To check the token signature, Grafana will fetch the Cognito public keys specified on the GF_AUTH_JWT_JWK_SET_URL environment variable. Finally, Grafana will check the token issuer to make sure it hasn’t been forged by another user pool using the GF_AUTH_JWT_EXPECT_CLAIMS environment variable.

To summarize, the authentication flow looks like this :

authentificationgrana

With this authentication flow, a user can log in with a one-step authentication. Let's take a closer look at what happens during the login process.

As explained before, when a user wants to access Grafana, he is redirected to AWS Cognito login. After login into AWS Cognito, the Application Load Balancer adds AWSELB authentication session cookies client-side. Then, it forwards the user claims to Grafana by setting new headers server-side. As we configured Grafana to use the x-amzn-oidc-accesstoken JWT forwarded by the Application Load Balancer for authentication, the user will be authenticated to Grafana and directly access the application without going through Grafana login page!

You can see requests for the login process below: first, we try to access Grafana (request 86) and we are redirected to the AWS Cognito endpoint (request 87). After login on AWS Cognito, we are redirected to Grafana without going through Grafana login process (request 92). You can see in the headers of request 92 that AWSELB cookies are set client-side.

request92

Logout

However, we encountered a limitation with the logout functionality. When we clicked on the logout button, nothing happened: we were still logged in… Why are we observing this?

We configured Grafana to redirect the user to the Cognito logout endpoint upon logging out, with the following environment variable:

GF_AUTH_SIGNOUT_REDIRECT_URL="<https://poc-eks-alb-oidc.auth.eu-west-3.amazoncognito.com/logout?client_id=xxxxxx&logout_uri=https://grafana.padok.school/login>"

Below are the executed requests that illustrate the process:

cognitologout

  1. The user tries to logout. Grafana logout is called (request 108).
  2. As configured in GF_AUTH_SIGNOUT_REDIRECT_URL, the Cognito logout endpoint is called to disconnect the user from Cognito. It invalidates the user’s JWT (request 109).
  3. Since we configured the logout_uri to the Grafana login page, the user is redirected to it. The client-side AWSELB cookies are still present. As a result, the Application Load Balancer recognizes the user and asks AWS Cognito to generate a new valid JWT, forwarding it to Grafana (request 110).
  4. The user is logged back in! (request 111)

Consequently, the user doesn't feel logged out because when they click on the logout button, they are immediately logged back in! This is due to the non-deletion of AWSELB client-side cookies.

We tried to delete the AWSELB cookies client-side manually in Chrome DevTools.

chrome-devtools

We observed that if the user attempts to logout again, they are properly redirected to the Cognito login page because they are no longer authenticated with the Application Load Balancer.

load-balancer

We found the following information in AWS documentation: “When an application needs to log out an authenticated user, it should set the expiration time of the authentication session cookie to -1 and redirect the client to the IdP logout endpoint”

Therefore, it is necessary for the application to handle the removal of client-side AWSELB cookies for the logout functionality to work correctly. This is a major limitation, as every third-party application will have the same issue. However, this implementation would be feasible with an application on which we can configure cookie deletion at logout.

Conclusion

We hope this article has given you an overview of the implementation of a centralized authentication with an Application Load Balancer. This implementation follows the concept of zero trust, an approach that challenges the conventional belief of trusting everything inside the network perimeter.

With zero trust, organizations adopt a more proactive and granular approach to security, where every user, device, and application is treated as potentially untrusted. By implementing application-level authentication through an Application Load Balancer, organizations can embrace a zero-trust architecture. This implementation effectively enforces access controls and verifies identities, which ensures security and resilience in the face of evolving cyber risks.