Simplified SSO with AWS Application Load Balancer and Azure AD OIDC

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.

via AWS

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": "[email protected]***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:

Azure AD Login

After successful login, subsequent requests will have the AWSELBAuthSessionCookie cookie set.

Share this: Facebooktwitterlinkedin