G Suite authentication at the load-balancer

TL;DR extend and deploy this demo 🤓

Suppose you need to create a self-service web application so users can conveniently reset their corporate directory passwords (e.g. Microsoft Active Directory). Naturally, you want this app hosted publicly, but only available to your corporate users. Luckily, your organisation also happens to be fairly progressive and has bought into G Suite for email, storage, etc. as well as Amazon Web Services for cloud infrastructure.

In the summer of 2018, AWS have introduced a feature to their Elastic Load Balancers (v2) to allow authentication against Cognito user pools, which can in turn be mapped to federated identity providers, such as Google via SAML prototol.

The following describes how we implemented the above scenario using CloudFormation and a corporate G Suite account.

Getting the foundation right is important, so having a main.yml CFN stack containing nested resources of Type: 'AWS::CloudFormation::Stack' is a relatively scalable way to organise your software stack. The following is a stub example of what your main.yml parent template may look like.

---
AWSTemplateFormatVersion: 2010-09-09
Description: 'Cognito demo'


Metadata:
  'AWS::CloudFormation::Interface':
    ParameterGroups:
    - Label:
        default: 'Common parameters'
      Parameters:
      - TimeoutInMinutes
      - NotificationTopic
    - Label:
        default: 'Cognito parameters'
      Parameters:
      - MetadataURL
      - DomainName

Parameters:
  DomainName:
    Type: String
    Description: 'Specify domain name Application Load Balancer domain name.'
    Default: ''
  MetadataURL:
    Type: String
    Description: 'Specify SAML metadata URL.'
    Default: ''
  NotificationTopic:
    Description: 'Specify optional SNS topic for initial CloudFormation event notifications.'
    Type: String
    Default: ''
  TimeoutInMinutes:
    Description: 'Specify optional timeout in minutes for stack creation.'
    Type: Number
    Default: 60

Conditions:
  HasNotify: !Not [ !Equals [ '', !Ref 'NotificationTopic' ]]

Resources:
  LambdaStack:
    Type: 'AWS::CloudFormation::Stack'
    Properties:
      TemplateURL: ./lambda.yaml
      Parameters:
        NameTag: !Sub '${AWS::StackName}'
      NotificationARNs:
      - !If [ HasNotify, !Ref 'NotificationTopic', !Ref 'AWS::NoValue' ]
      TimeoutInMinutes: !Ref 'TimeoutInMinutes'
      Tags:
      - Key: Name
        Value: !Sub '${AWS::StackName}'

  CognitoStack:
    Type: 'AWS::CloudFormation::Stack'
    Properties:
      TemplateURL: ./cognito.yaml
      Parameters:
        NameTag: !Sub '${AWS::StackName}'
        CustomResourceLambdaArn: !GetAtt [ LambdaStack, Outputs.CustomResourceLambdaArn ]
        DomainName: !Sub '${AWS::StackName}.${DomainName}'
        MetadataURL: !Sub '${MetadataURL}'
      NotificationARNs:
      - !If [ HasNotify, !Ref 'NotificationTopic', !Ref 'AWS::NoValue' ]
      TimeoutInMinutes: !Ref 'TimeoutInMinutes'
      Tags:
      - Key: Name
        Value: !Sub '${AWS::StackName}'

Outputs:
  StackName:
    Value: !Ref 'AWS::StackName'
    Export:
      Name: !Sub 'StackName-${AWS::StackName}'
  LambdaStack:
    Value: !GetAtt [ LambdaStack, Outputs.StackName ]
    Export:
      Name: !Sub 'LambdaStackName-${AWS::StackName}'
  CognitoStack:
    Value: !GetAtt [ CognitoStack, Outputs.StackName ]
    Export:
      Name: !Sub 'CognitoStackName-${AWS::StackName}'

In our main template, we nest a generic custom resource provider Lambda template, which will create our Cognito resources, which are not supported by native CFN at the time of writing.

⚠️make sure to review the IAM permissions in lambda-template.yaml and adjust as required

We also nest a second stack, containing Cognito resources, as follows.

---
AWSTemplateFormatVersion: 2010-09-09
Description: 'Cognito resources'

Parameters:
  NameTag:
    Type: String
  CustomResourceLambdaArn:
    Type: String
  DomainName:
    Type: String
  MetadataURL:
    Type: String

Conditions:
  HasDomain: !Not [ !Equals [ '', !Ref 'DomainName' ]]
  HasMetadata: !Not [ !Equals [ '', !Ref 'MetadataURL' ]]

Resources:
  UserPool:
    Type: 'AWS::Cognito::UserPool'
    Properties:
      UserPoolName: !Sub '${NameTag}'
      Schema:
      - Name: email
        Required: true
        Mutable: true
      UsernameAttributes:
      - email

  # https://github.com/ab77/cfn-generic-custom-resource
  UserPoolDomain:
    Type: 'Custom::CognitoUserPoolDomain'
    DependsOn: UserPool
    Properties:
      ServiceToken: !Sub '${CustomResourceLambdaArn}'
      AgentService: cognito-idp
      AgentType: client
      # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/cognito-idp.html#CognitoIdentityProvider.Client.create_user_pool_domain
      AgentCreateMethod: create_user_pool_domain
      AgentDeleteMethod: delete_user_pool_domain
      AgentWaitMethod: describe_user_pool_domain
      AgentWaitQueryExpr: '$.DomainDescription.Status'
      AgentWaitCreateQueryValues:
      - ACTIVE
      AgentWaitResourceId: Domain
      AgentResourceId: Domain
      AgentCreateArgs:
        Domain: !Sub '${NameTag}'
        UserPoolId: !Sub '${UserPool}'
      AgentDeleteArgs:
        Domain: !Sub '${NameTag}'
        UserPoolId: !Sub '${UserPool}'
      AgentWaitArgs:
        Domain: !Sub '${NameTag}'

  UserPoolIdentityProvider:
    Type: 'Custom::CognitoUserPoolIdentityProvider'
    Condition: HasMetadata
    DependsOn: UserPool
    Properties:
      ServiceToken: !Sub '${CustomResourceLambdaArn}'
      AgentService: cognito-idp
      AgentType: client
      # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/cognito-idp.html#CognitoIdentityProvider.Client.create_user_pool_domain
      AgentCreateMethod: create_identity_provider
      AgentUpdateMethod: update_identity_provider
      AgentDeleteMethod: delete_identity_provider
      AgentResourceId: ProviderName
      AgentCreateArgs:
        UserPoolId: !Sub '${UserPool}'
        ProviderName: !Sub '${NameTag}-provider'
        AttributeMapping:
          email: emailAddress
        ProviderDetails:
          MetadataURL: !Sub '${MetadataURL}'
          IDPSignout: true
        ProviderType: SAML
      AgentUpdateArgs:
        UserPoolId: !Sub '${UserPool}'
        ProviderName: !Sub '${NameTag}-provider'
        AttributeMapping:
          email: emailAddress
        ProviderDetails:
          MetadataURL: !Sub '${MetadataURL}'
          IDPSignout: true
        ProviderType: SAML
      AgentDeleteArgs:
        UserPoolId: !Sub '${UserPool}'
        ProviderName: !Sub '${NameTag}-provider'

  UserPoolClient:
    Type: 'Custom::CognitoUserPoolClientSettings'
    DependsOn:
    - UserPool
    - UserPoolIdentityProvider
    Properties:
      ServiceToken: !Sub '${CustomResourceLambdaArn}'
      AgentService: cognito-idp
      AgentType: client
      # https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/cognito-idp.html#CognitoIdentityProvider.Client.create_user_pool_client
      AgentCreateMethod: create_user_pool_client
      AgentUpdateMethod: update_user_pool_client
      AgentDeleteMethod: delete_user_pool_client
      AgentResourceId: ClientId
      AgentCreateArgs: !Sub |
        {
          "UserPoolId": "${UserPool}",
          "ClientName": "${NameTag}-client",
          "GenerateSecret": true,
          "CallbackURLs": [
            "https://${DomainName}/oauth2/idpresponse"
          ],
          "LogoutURLs": [
            "https://${NameTag}.auth.${AWS::Region}.amazoncognito.com/saml2/logout"
          ],
          "SupportedIdentityProviders": [
            "${NameTag}-provider"
          ],
          "AllowedOAuthFlows": [
            "code"
          ],
          "AllowedOAuthScopes": [
            "openid"
          ],
          "AllowedOAuthFlowsUserPoolClient": true,
          "RefreshTokenValidity": 14
        }
      AgentUpdateArgs: !Sub |
        {
          "UserPoolId": "${UserPool}",
          "ClientName": "${NameTag}-client",
          "GenerateSecret": true,
          "CallbackURLs": [
            "https://${DomainName}/oauth2/idpresponse"
          ],
          "LogoutURLs": [
            "https://${NameTag}.auth.${AWS::Region}.amazoncognito.com/saml2/logout"
          ],
          "SupportedIdentityProviders": [
            "${NameTag}-provider"
          ],
          "AllowedOAuthFlows": [
            "code"
          ],
          "AllowedOAuthScopes": [
            "openid"
          ],
          "AllowedOAuthFlowsUserPoolClient": true,
          "RefreshTokenValidity": 14
        }
      AgentDeleteArgs:
        UserPoolId: !Sub '${UserPool}'

Outputs:
  StackName:
    Value: !Ref 'AWS::StackName'
    Export:
      Name: !Sub 'StackName-${AWS::StackName}'
  UserPoolId:
    Value: !Ref 'UserPool'
    Export:
      Name: !Sub 'UserPoolId-${AWS::StackName}'
  ProviderName:
    Value: !Sub '${UserPool.ProviderName}'
    Export:
      Name: !Sub 'ProviderName-${AWS::StackName}'
  ProviderURL:
    Value: !Sub '${UserPool.ProviderURL}'
    Export:
      Name: !Sub 'ProviderURL-${AWS::StackName}'
  UserPoolArn:
    Value: !Sub '${UserPool.Arn}'
    Export:
      Name: !Sub 'UserPoolArn-${AWS::StackName}'
  UserPoolClientId:
    Value: !Sub '${UserPoolClient}'
    Export:
      Name: !Sub 'UserPoolClientId-${AWS::StackName}'
  UserPoolDomain:
    Value: !Sub '${NameTag}.auth.${AWS::Region}.amazoncognito.com'
    Export:
      Name: !Sub 'UserPoolDomain-${AWS::StackName}'

Your main.yaml master template, should then contain additional shared nested resources, such as:

  • Route53
  • ACM
  • ELB
  • Application/compute resource(s)

For example, your route53.yaml could simply create a new DNS hosted zone for your compute resources as follows.

---
AWSTemplateFormatVersion: 2010-09-09
Description: 'Route53 resources'

Parameters:
  NameTag:
    Type: String
  VpcId:
    Type: String
  DomainName:
    Type: String

Resources:
  HostedZone:
    Type: 'AWS::Route53::HostedZone'
    Properties:
      HostedZoneConfig:
        Comment: !Sub '${NameTag} public domain name.'
      Name: !Sub '${DomainName}.'
      HostedZoneTags:
      - Key: Name
        Value: !Ref 'NameTag'

Outputs:
  R53StackName:
    Value: !Ref 'AWS::StackName'
    Export:
      Name: !Sub 'R53StackName-${AWS::StackName}'
  HostedZone:
    Value: !Ref 'HostedZone'
    Export:
      Name: !Sub 'HostedZone-${AWS::StackName}'

Your acm.yaml nested stack would create an SSL certificate your load-balancer as follows.

---
AWSTemplateFormatVersion: 2010-09-09
Description: 'Certificate Manager (ACM) resources'

Parameters:
  NameTag:
    Type: String
  DomainName:
    Type: String
  ValidationDomain:
    Type: String

Resources:
  SSLCertificate:
    Type: 'AWS::CertificateManager::Certificate'
    Properties:
      DomainName: !Sub '*.${DomainName}'
      SubjectAlternativeNames:
      - !Ref 'DomainName'
      - !Sub '*.${DomainName}'
      - !Ref 'ValidationDomain'
      - !Sub '*.${ValidationDomain}'
      DomainValidationOptions:
      - DomainName: !Ref 'DomainName'
        ValidationDomain: !Ref 'ValidationDomain'
      Tags:
      - Key: Name
        Value: !Sub '${NameTag}'

Outputs:
  ACMStackName:
    Value: !Ref 'AWS::StackName'
    Export:
      Name: !Sub 'ACMStackName-${AWS::StackName}'
  SSLCertificateArn:
    Value: !Ref 'SSLCertificate'
    Export:
      Name: !Sub 'SSLCertificateArn-${AWS::StackName}'

⚠️ it helps to have an email ValidationDomain where all the common email aliases (e.g. postmaster) are forwared to an administrator's email address (or better a Slack channel) so that the ACM approval requests can be actioned

Once you have Route53 and ACM wired in in your main.yaml, it is time to add and wire in elb.yaml, which will contain your load balancer. This nested stack will take outputs from your other stacks as input parameters (e.g. UserPoolId from Cognito nested stack). The paired dwn version could look like this.

---
AWSTemplateFormatVersion: 2010-09-09
Description: 'ELB resources'

Parameters:
  NameTag:
    Type: String
  VpcId:
    Type: String
  AZSeleKtor:
    Type: String
  PublicSubnetIds:
    Type: String
  SSLCertificateArn:
    Type: String
  DomainName:
    Type: String
  HostedZone:
    Type: String
  PublicSecurityGroupId:
    Type: String
  UserPoolArn:
    Type: String
  UserPoolClientId:
    Type: String
  UserPoolId:
    Type: String


Conditions:
  HasMultiAZ2: !Equals [ 'MultiAZ-2', !Ref 'AZSeleKtor' ]
  HasMultiAZ3: !Equals [ 'MultiAZ-3', !Ref 'AZSeleKtor' ]


Resources:
  ApplicationLoadBalancer:
    Type: 'AWS::ElasticLoadBalancingV2::LoadBalancer'
    Properties:
      Scheme: 'internet-facing'
      LoadBalancerAttributes:
      - Key: access_logs.s3.enabled
        Value: false
      Subnets: !If
      - HasMultiAZ2
      - - !Select [ 0, !Split [ ',', !Ref 'PublicSubnetIds' ]]
        - !Select [ 1, !Split [ ',', !Ref 'PublicSubnetIds' ]]
      - - !Select [ 0, !Split [ ',', !Ref 'PublicSubnetIds' ]]
        - !Select [ 1, !Split [ ',', !Ref 'PublicSubnetIds' ]]
        - !Select [ 2, !Split [ ',', !Ref 'PublicSubnetIds' ]]
      SecurityGroups:
      - !Ref 'PublicSecurityGroupId'
      Type: 'application'
      IpAddressType: 'dualstack'
      Tags:
      - Key: Name
        Value: !Sub '${NameTag}'

  ALBTargetGroup:
    Type: 'AWS::ElasticLoadBalancingV2::TargetGroup'
    Properties:
      HealthCheckPort: 8080
      HealthCheckProtocol: 'HTTP'
      Port: 8080
      Protocol: 'HTTP'
      VpcId: !Ref 'VpcId'
      Tags:
      - Key: Name
        Value: !Sub '${NameTag}-http'

  HTTPListener:
    Type: 'AWS::ElasticLoadBalancingV2::Listener'
    Properties:
      DefaultActions:
      - RedirectConfig:
          Host: '#{host}'
          Path: '/#{path}'
          Port: 443
          Protocol: 'HTTPS'
          Query: '#{query}'
          StatusCode: HTTP_301
        Type: redirect
      LoadBalancerArn: !Ref 'ApplicationLoadBalancer'
      Port: 80
      Protocol: 'HTTP'

  HTTPSListener:
    Type: 'AWS::ElasticLoadBalancingV2::Listener'
    Properties:
      DefaultActions:
      - Type: authenticate-cognito
        Order: 1
        AuthenticateCognitoConfig:
          Scope: 'openid'
          SessionTimeout: 604800
          UserPoolArn: !Ref 'UserPoolArn'
          UserPoolClientId: !Ref 'UserPoolClientId'
          UserPoolDomain: !Sub '${NameTag}'
      - Type: forward
        TargetGroupArn: !Ref 'ALBTargetGroup'
        Order: 2
      LoadBalancerArn: !Ref 'ApplicationLoadBalancer'
      Port: 443
      Protocol: 'HTTPS'
      SslPolicy: 'ELBSecurityPolicy-2016-08'
      Certificates:
      - CertificateArn: !Ref 'SSLCertificateArn'

  RecordSet:
    Type: 'AWS::Route53::RecordSet'
    Properties:
      HostedZoneId: !Ref 'HostedZone'
      Comment: !Sub '${NameTag} load balancer with Cognito IdP via Google SAML authentication.'
      Name: !Sub '${NameTag}.${DomainName}.'
      Type: A
      AliasTarget:
        HostedZoneId: !GetAtt ApplicationLoadBalancer.CanonicalHostedZoneID
        DNSName: !GetAtt ApplicationLoadBalancer.DNSName
        EvaluateTargetHealth: true

Outputs:
  ELBStackName:
    Value: !Ref 'AWS::StackName'
    Export:
      Name: !Sub 'ELBStackName-${AWS::StackName}'
  ALBTargetGroup:
    Value: !Ref 'ALBTargetGroup'
    Export:
      Name: !Sub 'ALBTargetGroup-${AWS::StackName}'
  ALBTargetGroupFullName:
    Value: !GetAtt ALBTargetGroup.TargetGroupFullName
    Export:
      Name: !Sub 'ALBTargetGroupFullName-${AWS::StackName}'
  ALBTargetGroupName:
    Value: !GetAtt ALBTargetGroup.TargetGroupName
    Export:
      Name: !Sub 'ALBTargetGroupName-${AWS::StackName}'
  ApplicationLoadBalancerDNSName:
    Value: !GetAtt ApplicationLoadBalancer.DNSName
    Export:
      Name: !Sub 'ApplicationLoadBalancerDNSName-${AWS::StackName}'
  ALBArn:
    Value: !Ref 'ApplicationLoadBalancer'
    Export:
      Name: !Sub 'ALBArn-${AWS::StackName}'
  ALBCanonicalHostedZoneID:
    Value: !GetAtt ApplicationLoadBalancer.CanonicalHostedZoneID
    Export:
      Name: !Sub 'ALBCanonicalHostedZoneID-${AWS::StackName}'
  ALBFullName:
    Value: !GetAtt ApplicationLoadBalancer.LoadBalancerFullName
    Export:
      Name: !Sub 'ALBFullName-${AWS::StackName}'
  ALBName:
    Value: !GetAtt ApplicationLoadBalancer.LoadBalancerName
    Export:
      Name: !Sub 'ALBName-${AWS::StackName}'
  DNSName:
    Value: !Sub '${NameTag}.${DomainName}'
    Export:
      Name: !Sub 'DNSName-${AWS::StackName}'

At this point, your base resources are in place so it is up to you what you want to host behind your load balancer. You could spin up an ECS cluster, an Elastic Beasnstalk environment, a single EC2 instance or an Auto Scaling Group to host a self-service web application like we did.

The last step is to configure your Google IdP by creating a SAML app. A general approach is outlined here and here.

A demo stack is available as a starting point and can be extended with additional resources as outlined above.

Hope this helps to save time on (re)writing a lot of boiler-plate authentication code!

--belodetek 😬