Featured image of post AWS ALB OIDC with Entra ID

AWS ALB OIDC with Entra ID

Introduction

I do not want my app to perform OIDC, and I do not want unauthenticated traffic reaching my private subnets. I have a simple React SPA, and when keeping these prerequisites in mind would lead us to perform OIDC authentication using native AWS services. This can be at best done with the application load balancer (ALB). The ALB can be configured to perform OIDC authentication at the listener level. Sounds great, but like everything in life, there are some gotchas..

AWS ALB Entra ID OIDC Authentication

Let’s configure the HTTPS ALB listener to perform Entra ID OIDC authentication. As a prerequisite, we assume that you have already configured an Azure App Registration. This is a terraform snippet that shows the ALB listener configuration:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
locals {
  tenant_id              = "<Azure_AD_tenant_ID>"
  microsoft_login_url    = "https://login.microsoftonline.com/${local.tenant_id}"
  authorization_endpoint = "${local.microsoft_login_url}/oauth2/v2.0/authorize"
  issuer                 = "${local.microsoft_login_url}/v2.0"
  token_endpoint         = "${local.microsoft_login_url}/oauth2/v2.0/token"
  user_info_endpoint     = "https://graph.microsoft.com/oidc/userinfo"
  scope                  = "openid profile email"
  client_id              = "<FETCH_FROM_AZURE_APP_REGISTRATION>"
  client_secret          = "<FETCH_FROM_SECRET_MANAGER>"
  certificate_arn        = "<FETCH_FROM_ACM>"
}

resource "aws_lb_listener" "https" {
  load_balancer_arn = aws_lb.alb.arn
  port              = 443
  protocol          = "HTTPS"
  ssl_policy        = "ELBSecurityPolicy-TLS13-1-2-Res-2021-06"
  certificate_arn   = local.certificate_arn

  default_action {
    type = "authenticate-oidc"
    authenticate_oidc {
      authorization_endpoint = local.authorization_endpoint
      client_id              = local.client_id
      client_secret          = local.client_secret
      issuer                 = local.issuer
      token_endpoint         = local.token_endpoint
      user_info_endpoint     = local.user_info_endpoint
      scope                  = local.scope
    }
  }

  default_action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.backend.arn
  }
}

That’s it! Now you can access your application, and all HTTPS requests will be redirected to the Entra ID login page. After successful authentication, the user will be redirected back to the application as indicated in the target group arn target_group_arn = aws_lb_target_group.backend.arn. The callback URLs or redirect URIs configured for the application are: https://myapp.aws.cloud/oauth2/idpresponse.

All the above details for OIDC are available in the well-known endpoint that is a standardized JSON file containing the metadata such as the issuer, authorization endpoint, token endpoint, user info endpoint, etc. The well-known endpoint for Entra ID is available at the following URL: https://login.microsoftonline.com/<Azure_AD_tenant_ID>/.well-known/openid-configuration

Where is my ID token?

If you have done Entra ID OIDC authentication before, you would be used to receiving the ID, access, and refresh tokens back from the corresponding Microsoft endpoints. However, the AWS ALB does not return all of these tokens! The ALB only returs the original access token mapped to a header called x-amzn-oidc-accesstoken. It does not return the original ID token that it received from Entra ID. It is important to know this information beforehand if the system depends on the ID tokoen for authorization as well in addition to authentication since it contains the group memberships of the user as a claim called groups. The AWS ALB generates a separate JWT instead called x-amzn-oidc-data that contains the user claims received from the IdP user info endpoint and is signed by the ALB itself. The ALB forwards the x-amzn-oidc-* headers to its target group:

  • x-amzn-oidc-accesstoken: The access token from the token endpoint.
  • x-amzn-oidc-identity: The subject field (sub) from the user info endpoint.
  • x-amzn-oidc-data: The user claims, in JSON web tokens (JWT) format.

According to the official documentation: The Application Load Balancer creates a new access token when authenticating a user and only passes the access tokens and claims to the backend, however it does not pass the ID token information.

AWS ALB AuthN and Backend AuthZ

Since we are managing users in Entra ID, we also manage their group memberships there and can use these group memberships to authorize (AuthZ) the user in our app. The AWS ALB is responsible for authenticating (AuthN) the user, but not for authorizing (AuthZ) it. What is missing in the ALB tokens that would allow our backend to do AuthZ using Entra ID? It is the group memberships of the user that is present in the original ID token as a roles or groups claim.

The roles and groups claims that our backend application needs is not passed to the ALB x-amzn-oidc-data since the ALB fetches the user claims from the user info endpoint. The Entra ID user info endpoint does not return the group memberships of the user. This means that the backend application does not receive any information about the Entra ID group memberships of the logged-in user and cannot decide if the user has admin or developer permissions, for example.

Here is an example of a decoded x-amzn-oidc-data JWT that contains the user claims:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
{
  "typ": "JWT",
  "kid": "bc1052be-9015-45ab-9cd0-d59f70cd3d58",
  "alg": "ES256",
  "iss": "https://login.microsoftonline.com/<Azure_AD_tenant_ID>/v2.0",
  "client": "<Azure_Application_Client_ID>",
  "signer": "arn:aws:elasticloadbalancing:eu-central-1:123456789012:loadbalancer/app/demo/f88f43ca1f389b69",
  "exp": 1782336913
}.{
  "sub": "(same value as x-amzn-oidc-identity)",
  "name": "Firstname Lastname",
  "family_name": "Lastname",
  "given_name": "Firstname",
  "picture": "https://graph.microsoft.com/v1.0/me/photo/$value",
  "email": "email@domain.com",
  "exp": 1782336913,
  "iss": "https://login.microsoftonline.com/<Azure_AD_tenant_ID>/v2.0"
}.[
  Signature
]

The ALB signed the token as shown in the signer claim in the header but Entra ID issued it as shown in the iss claim. The payload contains the name and email address of the user but no information about the group memberships.

The x-amzn-oidc-access header contains the access token and the x-amzn-oidc-identity header contains the subject field (sub) which is already present in x-amzn-oidc-data.

Here is a decoded ID token retrieved directly from Entra ID after successful authentication:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
{
  "typ": "JWT",
  "alg": "RS256",
  "kid": "Xt-o7hDbpupAz-ZPm6HxCFWS3cI"
}.{
  "aud": "AZURE_APP_REGISTRATION_CLIENT_ID",
  "iss": "https://login.microsoftonline.com/AZURERM_TENANT_ID/v2.0",
  "iat": 1779217576,
  "nbf": 1779217576,
  "exp": 1779221476,
  "email": "email@domain.com",
  "groups": [
    "7290ecbc-ce9e-118b-9c11-7811fc885ef3",
    "490fbb8c-ecff-4c80-7a0d-1c76a112c6dc"
  ],
  "idp": "https://sts.windows.net/AZURERM_TENANT_ID/",
  "name": "Firstname Lastname",
  ...
  "roles": [
    "MYAPP1.READONLY",
    "MYAPP2.WRITESOMEDATA",
    "MYAPP3.ADMIN"
  ],
  ...
  "tid": "AZURERM_TENANT_ID",
  ...
  "ver": "2.0"
}.[
  Signature
]

Notice the groups claim that contains the group memberships of the user as group IDs in addition to the roles claim that contains the mapped roles of the user.

User Group Memberships

We need to get the group memberships of the user in order to authorize the user in our backend application. Here is a sequence diagram of the problem definition:

  sequenceDiagram
    User ->> ALB: Authenticate
    ALB ->> Entra ID: Entra ID authentication
    Entra ID -->> ALB: ID and Access Tokens
    Note right of Backend: ALB creates its own tokens x-amzn-oidc-*
    ALB ->> Backend: x-amzn-oidc-accesstoken, x-amzn-oidc-identity, x-amzn-oidc-data
    Backend --x User: admin user or developer? Missing group memberships in x-amzn-oidc-data

We have two practical possible solutions that can be used to get the group memberships of the user:

  • call the Graph API from the backend using the original access token
  • use Cognito federated OIDC with custom attributes mapped to the groups claim in the ID token

Group Memberships from the Graph API using the Backend

One way to get the group memberships of the user is to use the Microsoft Graph API. Since the ALB returns the x-amzn-oidc-accesstoken header that contains the original access token, we can use it to call the Microsoft Graph API. Namely, we can call the List member of endpoint to get the group memberships of the current user as shown in the sequence diagram below:

  sequenceDiagram
    User ->> ALB: Authenticate
    ALB ->> Entra ID: Entra ID authentication
    Entra ID -->> ALB: ID and Access Tokens
    ALB ->> Backend: x-amzn-oidc-accesstoken, x-amzn-oidc-identity, x-amzn-oidc-data
    Note right of Backend: Use x-amzn-oidc-accesstoken for Graph API call
    Backend ->> Graph API: GET /me/memberOf
    Graph API -->> Backend: User Groups
    Backend -->> User: Authorize user according to group memberships

Here is an example of a decoded x-amzn-oidc-accesstoken JWT that contains the original access token issued by Entra ID:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
{
  "typ": "JWT",
  "nonce": "5pAPQQajdTfmT3N0jazjZvaI0NNWKX4gP4Zxm2T7p4M",
  "alg": "RS256",
  "x5t": "aFkmKVFc-4WV6sXCBvNZkXI505Y",
  "kid": "aFkmKVFc-4WV6sXCBvNZkXI505Y"
}.{
  "aud": "00000003-0000-0000-c000-000000000000",
  "iss": "https://sts.windows.net/AZURERM_TENANT_ID/",
  "iat": 1782336493,
  "nbf": 1782336493,
  "exp": 1782341108,
  ...
  "app_displayname": "AZURE_APP_REGISTRATION",
  "appid": "AZURE_APP_REGISTRATION_CLIENT_ID",
  "appidacr": "1",
  "family_name": "Lastname",
  "given_name": "Firstname",
  "idtyp": "user",
  "ipaddr": "...",
  "name": "Firstname Lastname",
  ...
  "scp": "email openid profile User.Read",
  ...
  "tid": "AZURERM_TENANT_ID",
  "unique_name": "email@domain.com",
  "upn": "email@domain.com",
  ...
}.[
  Signature
]

Here’s the response from the user info endpoint fromm where the ALB retrieves the user claims:

1
2
3
4
5
6
7
8
9
curl -H "Authorization: Bearer $ACCESS_TOKEN" https://graph.microsoft.com/oidc/userinfo | jq

{
  "sub": "...",
  "name": "Firstname Lastname",
  "family_name": "Lastname",
  "given_name": "Firstname",
  "picture": "https://graph.microsoft.com/v1.0/me/photo/$value"
}

Here’s an example of a call to the Graph API to get the basic information of the current user:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
curl -H "Authorization: Bearer ${x-amzn-oidc-accesstoken}" https://graph.microsoft.com/v1.0/me/ | jq

{
  "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#users/$entity",
  "businessPhones": [],
  "displayName": "username",
  "givenName": "Firstname",
  "jobTitle": null,
  "mail": null,
  "mobilePhone": null,
  "officeLocation": null,
  "preferredLanguage": null,
  "surname": "Lastname",
  "userPrincipalName": "email@domain.com",
  "id": "45de6ef6-99d3-4816-8e87-d4c58da93195"
}

Here’s an example of a call to the Graph API to get the group memberships of the current user:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
curl -H "Authorization: Bearer ${x-amzn-oidc-accesstoken}" https://graph.microsoft.com/v1.0/me/memberOf | jq

{
  "@odata.context": "https://graph.microsoft.com/v1.0/$metadata#directoryObjects",
  "value": [
    {
      "@odata.type": "#microsoft.graph.group",
      "id": "96e87c46-5e04-4811-83f4-529925f7cad3",
      ...
    },
    {
      "@odata.type": "#microsoft.graph.group",
      "id": "c5643819-f668-4cd5-8420-9dd925efc071",
      ...
    },
    ...
  ]
}

The response contains a list of maps that contain the group ID and some other information of the group. One can also cache the results for 5 to 10 minutes using a DynamoDB table. This way, a new request to the Graph API would only take place in case of a cache lookup miss in DynamoDB. Keep in mind that this means any changes to the group memberships of the user inside Entra ID will not be reflected in the application until the cache is cleared after 5 or 10 minutes.

Group Memberships using Cognito Federated OIDC

This is addressed in our second blog post on this topic.

Misc.

Here are some important miscellaneous topics to consider when dealing with ALB authentication.

ALB JWT Signature Verification

The backend has to verify the signature of the ALB JWTs before doing any authorization based on the claims. Validate that the signer field contains the expected ALB ARN. You can also use AWS JWT Verify

AWSELBAuthSessionCookie

The ALB creates an encrypted cookie named AWSELBAuthSessionCookie after successful authentication. If the cookie is present and valid, the ALB will not authenticate the user again. Otherwise, the ALB will redirect the user to the authentication page. Only the ALB can decode the cookie data, and the user cannot not see any meaningful information in the cookie. Here is an example of the cookie value with some content in the middle redacted:

1
9VYTnUxOk5E8mpOG5LX5H5BeDvn05VC84PZ7nWgaNuu0ib129DTdwl1QguLpgINiK8x41ZK1rc3WtHsLnu/...Kygcam4PFLY1akYbNDNAaupFDIJg2poeN3EF0AQ1NWWBxbYYMB0A8d

ALBeast Security Bypass

We have seen in the previous section a decoded x-amzn-oidc-data JWT:

  • it was signed by the ALB
  • it was issued by Entra ID
  • it did not contain an aud claim

This has led to the discovery of ALBeast. More information can be found here.

In order to make sure that you are not a victim of this vulnerability:

  • make sure that the ALB targets accept traffic only from the ALB by referencing the ALB security group in the inbound rules of the target security group
  • implement signature validation for the ALB JWT and confirm the signature by your ALB ARN

An official AWS spokesperson response to this according to The Hacker News:

It is incorrect to call this an authentication and authorization bypass of AWS Application Load Balancer (ALB) or any other AWS service because the technique relies on a bad actor already having direct connectivity to a misconfigured customer application that does not authenticate requests. We recommend customers configure their applications to only accept requests from their ALB by using security groups and by following the ALB security best practices. A small fraction of a percent of AWS customers have applications potentially misconfigured in this way, significantly fewer than the researchers’ estimate. We have contacted each one of these customers directly to share best practices for configuring applications which use ALB.

Conclusion

In this post, we have seen how to configure Entra ID OIDC authentication for an AWS ALB listener. We have also seen one way on how to get the group memberships of the user in the backend application by using the Microsoft Graph API. In part 2 of this series, we will see how to use Cognito Federated OIDC to achieve the same result. We also discussed some miscellaneous security topics that need to be considered.

References: