The Wayback Machine - https://web.archive.org/web/20231004191646/https://anton.belodedenko.me/google-saml-auth-load-balancer/

home

Motorhome

Netflix

FortiGate

Home Improvement

Belodedexit

7 February 2019
/ belodetek Ltd.

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 😬

Anton Belodedenko

I am a jack of all trades, master of none (DevOps). My wife and I ski, snowboard and rock climb. Oh, and I like Futurama, duh!

Read More

— on many things randomly and in no particular order —

belodetek Ltd.

A real hand job...🕹

on reducing AWS Elastic Beanstalk costs... 💰

Deploy private CAs with CloudFormation

See all 6 posts →

belodetek Ltd.

Deploy private CAs with CloudFormation

Create private CA programmatically in AWS using CloudFormation without manual intervention 🤓

Anton Belodedenko

belodetek Ltd.

CloudFormation Generic Custom Resources

CloudFormation generic custom resource provider written in Python. Effectively a CFN to (boto3)API bridge with Lambda. Stop creating a new custom_resource.py for every feature CFN doesn't support and generalize it! 👊🤓

Anton Belodedenko

on many things randomly and in no particular order

—

G Suite authentication at the load-balancer

Share this

All content copyright Anton and Anna Belodedenko © 2023

Latest Posts

Ghost