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 😬