AWS Application Loan Balancers support what I think is an underappreciated feature: the ability to authenticate requests (via OIDC) at Layer 7. This allows developers to keep almost all authentication outside of the application layer code. An ideal use-case could be an internal-only web application that requires authentication, but little if any RBAC authorization.
This AWS blog post is as a great primer on how the feature works:
ALB Authentication works by defining an authentication action in a listener rule. The ALB’s authentication action will check if a session cookie exists on incoming requests, then check that it’s valid. If the session cookie is set and valid then the ALB will route the request to the target group with
X-AMZN-OIDC-*
headers set.
ALB Authentication supports both Cognito and generic OIDC Identity Providers. For this post, I’m going to focus on integration with Azure AD’s OIDC – managed with Terraform and the Serverless Framework.
Azure AD – Enterprise App Configuration
In order to setup an OIDC integration with Azure AD, you’ll first need to configure an Enterprise Application. Microsoft already has a tutorial on how to do this manually via the UI in the Azure portal, so my focus will be on deployment using the Azure AD Terraform provider. This approach has the added benefit of automatically providing the ALB Authentication configuration inputs (for Serverless) using the resulting Terraform outputs.
Let’s start with the Azure application’s configuration:
data "azuread_application_published_app_ids" "well_known" {}
resource "azuread_service_principal" "msgraph" {
application_id = data.azuread_application_published_app_ids.well_known.result.MicrosoftGraph
use_existing = true
}
resource "azuread_application" "app" {
display_name = local.app_title
group_membership_claims = ["SecurityGroup"]
sign_in_audience = "AzureADMyOrg"
web {
homepage_url = local.base_url
redirect_uris = [local.reply_url]
implicit_grant {
access_token_issuance_enabled = false
id_token_issuance_enabled = false
}
}
required_resource_access {
resource_app_id = azuread_service_principal.msgraph.application_id
resource_access {
id = azuread_service_principal.msgraph.oauth2_permission_scope_ids["openid"]
type = "Scope"
}
}
}
resource "azuread_service_principal" "service_principal" {
application_id = azuread_application.app.application_id
tags = ["WindowsAzureActiveDirectoryIntegratedApp"]
app_role_assignment_required = true
}
resource "azuread_application_password" "app_password" {
application_object_id = azuread_application.app.id
display_name = local.app_title
}
resource "azuread_service_principal_delegated_permission_grant" "delegated_grant" {
service_principal_object_id = azuread_service_principal.service_principal.object_id
resource_service_principal_object_id = azuread_service_principal.msgraph.object_id
claim_values = ["openid"]
}
A couple things to note in the Terraform code above. One, we have app_role_assignment_required
set, which will require specific users to be assigned to the application in Azure in order to successfully SSO. Two, the azuread_service_principal_delegated_permission_grant
resource – this will grant admin consent for the openid
permission on behalf of the enterprise.
Back in Terraform, we’ll expose the resulting app configuration parameters (required for the ALB listener) to AWS with SSM:
resource "aws_ssm_parameter" "tenant_id" {
name = "/web-app/AZURE_TENANT_ID"
type = "String"
value = data.azuread_client_config.current.tenant_id
}
resource "aws_ssm_parameter" "client_id" {
name = "/web-app/AZURE_CLIENT_ID"
type = "String"
value = azuread_application.app.application_id
}
resource "aws_ssm_parameter" "client_secret" {
name = "/web-app/AZURE_CLIENT_SECRET"
type = "SecureString"
value = azuread_application_password.app_password.value
}
Serverless – ALB Listener
With the Azure Enterprise App created, we can move on to configuring the ALB itself. Let’s start with defining those resources:
Resources:
############
# ALB Resources
############
ALBSecurityGroupEgress:
Type: AWS::EC2::SecurityGroupEgress
Properties:
CidrIp: '0.0.0.0/0' # OIDC needs external egress
GroupId: !GetAtt ALBSecurityGroup.GroupId
IpProtocol: tcp
FromPort: 443
ToPort: 443
ALBSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: ${self:service}-${self:provider.stage}-alb-sg
VpcId: ${self:custom.alb.vpc}
ALBSecurityGroupIngress:
Type: AWS::EC2::SecurityGroupIngress
Properties:
GroupId: !GetAtt ALBSecurityGroup.GroupId
IpProtocol: tcp
FromPort: ${self:custom.alb.port}
ToPort: ${self:custom.alb.port}
CidrIp: ${self:custom.alb.ingress}
LambdaFunctionPermission:
Type: AWS::Lambda::Permission
Properties:
Action: lambda:InvokeFunction
FunctionName: !GetAtt ApiLambdaFunction.Arn # Dynamic from function
Principal: elasticloadbalancing.amazonaws.com
ALBElasticLoadBalancer:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Subnets: ${self:custom.alb.subnets}
Scheme: ${self:custom.alb.scheme}
SecurityGroups:
- !GetAtt ALBSecurityGroup.GroupId
DependsOn:
- ALBSecurityGroup
ALBDNSRecord:
Type: AWS::Route53::RecordSet
Properties:
HostedZoneId: ${self:custom.alb.hostedZoneId}
Name: ${self:custom.alb.dnsName}.
AliasTarget:
DNSName: !GetAtt ALBElasticLoadBalancer.DNSName
HostedZoneId: !GetAtt ALBElasticLoadBalancer.CanonicalHostedZoneID
Type: A
LoadBalancerListener:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
Certificates:
- CertificateArn: ${self:custom.alb.certArn}
DefaultActions:
- Type: fixed-response
FixedResponseConfig:
ContentType: "text/plain"
MessageBody: ""
StatusCode: "404"
LoadBalancerArn:
Ref: ALBElasticLoadBalancer
Port: ${self:custom.alb.port}
Protocol: ${self:custom.alb.protocol}
Note the ALBSecurityGroupEgress
resource, indicating the ALB needs external egress (even for internal-only ALBs) so it can interact with the Azure ODIC APIs.
Next, we will wire up the ALB listener rule within Serverless, starting with the ALB Authorizer configuration:
custom:
oidc:
tenantId: '${ssm:/web-app/AZURE_TENANT_ID}'
clientId: '${ssm:/web-app/AZURE_CLIENT_ID}'
clientSecret: '${ssm:/web-app/AZURE_CLIENT_SECRET}'
provider:
name: aws
runtime: python3.9
timeout: 60
alb:
authorizers:
azureAdAuth:
type: oidc
authorizationEndpoint: https://login.microsoftonline.com/${self:custom.oidc.tenantId}/oauth2/v2.0/authorize
issuer: https://login.microsoftonline.com/${self:custom.oidc.tenantId}/v2.0
tokenEndpoint: https://login.microsoftonline.com/${self:custom.oidc.tenantId}/oauth2/v2.0/token
userInfoEndpoint: https://graph.microsoft.com/oidc/userinfo
onUnauthenticatedRequest: authenticate
clientId: ${self:custom.oidc.clientId}
clientSecret: ${self:custom.oidc.clientSecret}
Note the references to the custom.oidc
attributes, which are fetched dynamically from SSM and brought to you by the Terraform above. Next, hook up the authorizer to the ALB event on the Lambda function.
functions:
api:
handler: webapp.alb_handler
events:
- alb:
listenerArn: !Ref LoadBalancerListener
priority: 1
multiValueHeaders: true
authorizer: azureAdAuth
conditions:
path: "*"
After deploying, you’ll see something like this in the ALB’s rules UI:
K8s ALB Annotations
You can also achieve a similar configuration using ingress annotations in Kubernetes:
kind: Ingress
apiVersion: extensions/v1beta1
metadata:
name: ***REMOVED***
namespace: ***REMOVED***
annotations:
alb.ingress.kubernetes.io/auth-idp-oidc: >-
{"secretName":"***REMOVED***","issuer":"https://login.microsoftonline.com/***REMOVED***/v2.0","authorizationEndpoint":"https://login.microsoftonline.com/***REMOVED***/oauth2/v2.0/authorize","tokenEndpoint":"https://login.microsoftonline.com/***REMOVED***/oauth2/v2.0/token","userInfoEndpoint":"https://graph.microsoft.com/oidc/userinfo"}
alb.ingress.kubernetes.io/auth-on-unauthenticated-request: authenticate
alb.ingress.kubernetes.io/auth-scope: openid
alb.ingress.kubernetes.io/auth-session-cookie: AWSELBAuthSessionCookie
alb.ingress.kubernetes.io/auth-session-timeout: '604800'
alb.ingress.kubernetes.io/auth-type: oidc
OIDC Headers
After authenticating a request, the ALB adds additional headers containing the user claims before forwarding it to the target. The linked doc has an example of decoding the x-amzn-oidc-data
header – I’ve adapted it a bit to cache the public key, add logging, and verify the issuer:
AWS_REGION = os.getenv("AWS_REGION", "us-east-1")
OIDC_ISSUER = os.getenv("OIDC_ISSUER", "")
PUB_KEY = None
def decode_jwt(encoded_jwt):
log = logger.new(name="DecodeJwt")
global PUB_KEY
if not PUB_KEY:
log.debug("Initializing ALB public key")
# Step 1: Get the key id from JWT headers (the kid field)
jwt_headers = encoded_jwt.split(".")[0]
decoded_jwt_headers = base64.b64decode(jwt_headers)
decoded_jwt_headers = decoded_jwt_headers.decode("utf-8")
decoded_json = json.loads(decoded_jwt_headers)
kid = decoded_json["kid"]
# Step 2: Get the public key from regional endpoint
url = f"https://public-keys.auth.elb.{AWS_REGION}.amazonaws.com/{kid}"
req = requests.get(url)
PUB_KEY = req.text
else:
log.debug("Using cached public key")
# Step 3: Get the payload
log.debug("Decoding JWT...")
payload = jwt.decode(encoded_jwt, PUB_KEY, issuer=OIDC_ISSUER, algorithms=["ES256"])
log.debug("Decoded JWT", payload=payload)
return payload
And here’s an example decoded claims payload from Azure AD:
{
"sub": "***REMOVED***",
"name": "Randy Westergren",
"family_name": "Westergren",
"given_name": "Randolph",
"picture": "https://graph.microsoft.com/v1.0/me/photo/$value",
"email": "randy.westergren@***REMOVED***.com",
"exp": 1665076372,
"iss": "https://login.microsoftonline.com/***REMOVED***/v2.0"
}
You can use this for anything from logging user requests, to expanding the app to add authorization rules.
Login
Now when you visit your protected paths, you’ll first be redirected to the Azure AD sign-in:
After successful login, subsequent requests will have the AWSELBAuthSessionCookie
cookie set.