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.
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.
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.
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.
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.
Here is an article about setting up an SSH bastion host on the cloud using sshuttle.
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.
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.
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.
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.
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.
Here is the authentification flow:
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.
We will build the following architecture as a proof of concept. The source code is available on GitHub.
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:
If we try to reach a service behind the AWS Application Load Balancer, we are correctly redirected to an AWS Cognito login form.
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
}
}
The authentication method described in the Proof of Concept comes with some limitations.
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 :
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.
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:
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).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).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.
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.
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.
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.