Pleased to meet you, hope you guess my name...
… but if you want to know your users in AWS, it is now easier than ever, thanks to 2 “recently” announced ALB features;
- Application Load Balancer Built-in Authentication
- Lambda functions as targets for Application Load Balancers
First one allows you to add authentication for existing application simply by placing an ALB in front of your service/servers and adding an authentication step in listener configuration. Second one makes it possible to replace those servers with Lambda functions and go serverless!
Together these make it easy to build a demo application that will authenticate users from Cognito as a single Cloudformation template. For simplicity I’m assuming there is already a 1) Cognito Userpool, 2) Route53 Zone and 3) matching SSL certificate in ACM available.
SAM Template will build and configure ALB, create lambda function backend and configure Cognito userpool using CFN custom resource (another Lambda function). Custom resource is required because Cloudformation doesn’t support all features of Cognito configuration :-(
Cloudformation Template
I’m not going to analyze the whole template line by line, but just highlight the most interesting details that may not be 100% obvious when doing this for the first time.
AWS::ElasticLoadBalancingV2::Listener is where most of authentication config is.
This is rather self-explanatory and as I don’t have any path based routing to different backend functions
I only have DefaultActions
-block.
Listener:
Type: AWS::ElasticLoadBalancingV2::Listener
DependsOn: CognitoAppClientSettings
Properties:
DefaultActions:
- Type: authenticate-cognito
Order: 1
AuthenticateCognitoConfig:
OnUnauthenticatedRequest: authenticate
Scope: openid
SessionCookieName: AWSELBAuthSessionCookie
SessionTimeout: 60
UserPoolArn: !Ref CognitoUserpoolArn
UserPoolDomain: !Ref CognitoUserpoolDomain
UserPoolClientId: !Ref CognitoAppClient
- Type: forward
Order: 2
TargetGroupArn: !Ref LambdaTargetGroup
LoadBalancerArn: !Ref ALB
Port: 443
Protocol: HTTPS
Certificates:
- CertificateArn: !Ref SSLCert
AWS::ElasticLoadBalancingV2::TargetGroup only defines the function that is executed to
process HTTP request. However, to allow ALB to execute the function you must also define
AWS::Lambda::Permission and grant access to elasticloadbalancing.amazonaws.com
. Permission
must be granted before target group is created, that’s why the explicit dependency on LambdaExecPermission
.
LambdaTargetGroup:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
DependsOn: LambdaExecPermission
Properties:
HealthCheckEnabled: False
Name: !Ref AWS::StackName
TargetType: lambda
Targets:
- Id: !GetAtt JWTverifier.Arn
LambdaExecPermission:
Type: AWS::Lambda::Permission
Properties:
Action: lambda:InvokeFunction
FunctionName: !GetAtt JWTverifier.Arn
Principal: elasticloadbalancing.amazonaws.com
AWS::Cognito::UserPoolClient has very limited Cloudformation support. All you can do is create a client for given userpool and define what user attributes will be delivered.
CognitoAppClient:
Type: AWS::Cognito::UserPoolClient
Properties:
ClientName: !Sub '${AWS::StackName} ALB'
ExplicitAuthFlows:
- USER_PASSWORD_AUTH
GenerateSecret: True
RefreshTokenValidity: 1
UserPoolId: !Select [1, !Split [ 'userpool/', !Ref CognitoUserpoolArn ]]
ReadAttributes:
- email
- email_verified
Rest of UserPoolClient configuration is handled by calling updateUserPoolClient
AWS API via custom resource. I’m using generic Lambda function which will call AWS API defined in
Action
with Parameters
.
CognitoAppClientSettings:
Type: Custom::CognitoUserPoolClientSettings
Properties:
ServiceToken: !GetAtt CustomResource.Arn
Service: CognitoIdentityServiceProvider
Create:
Action: updateUserPoolClient
Parameters:
UserPoolId: !Select [1, !Split [ 'userpool/', !Ref CognitoUserpoolArn ]]
ClientId: !Ref CognitoAppClient
AllowedOAuthFlows: [ code ]
AllowedOAuthScopes: [ openid ]
SupportedIdentityProviders: [ COGNITO ]
AllowedOAuthFlowsUserPoolClient: true
CallbackURLs:
- !Sub https://${AWS::StackName}.${R53Zone}/oauth2/idpresponse
UPDATE 9/10/2019 After AWS::Cognito::UserPoolClient update it is no longer necessary to modify client settings with custom resource but all parameters are supported by standard cloudformation. Here is the updated version of template.
See also Authenticate Users Using an Application Load Balancer for the summary of all things included in ALB authentication configuration.
Lambda Backend
My demo function does 2 things; it validates JWT token from ALB and returns ALB compatible respose. ALB and function must on the same AWS account, but not necessary in the same region. Function doesn’t have to be in VPC to work with ALB, but that might be needed if the code does access data stores in VPC, for example.
import requests
import base64
import json
import jwt
def lambda_handler(event, context):
# Step 1: Get the key id from JWT headers (the kid field)
encoded_jwt = event['headers']['x-amzn-oidc-data']
jwt_fields = encoded_jwt.split('.')
jwt_headers = jwt.get_unverified_header(encoded_jwt)
jwt_sig = jwt_fields[2]
# NOTE: Payload is in base64 clear-text, but you should use jwt.decode() to verify the signature!
# jwt_payload = json.loads(base64.b64decode(jwt_fields[1]))
# Step 2: Get the public key from regional endpoint
aws_region = context.invoked_function_arn.split(':')[3]
url = 'https://public-keys.auth.elb.' + aws_region + '.amazonaws.com/' + jwt_headers['kid']
pub_key = requests.get(url).text
# Step 3: Get the payload
jwt_payload = jwt.decode(encoded_jwt, pub_key, algorithms=[jwt_headers['alg']])
response = {
"statusCode": 200,
"statusDescription": "200 OK",
"isBase64Encoded": False,
"headers": {
"Content-Type": "text/html; charset=utf-8"
}
}
response['body'] = ( "<html><head><title>Hello JWT</title></head><body><pre>"
+ 'Encoded JWT:\n' + encoded_jwt + '\n\n'
+ 'JWT Headers:\n' + json.dumps(jwt_headers, indent=2, sort_keys=True) + '\n\n'
+ 'JWT Payload:\n' + json.dumps(jwt_payload, indent=2, sort_keys=True) + '\n\n'
+ 'JWT Signature:\n' + jwt_sig + '\n'
+ "</pre></body></html>" )
return response
Lambda Layer
Lambda Python environment has many frequently used packages installed but I needed PyJWT and it’s dependencies to verify JWT token from ALB. I choosed to put missing python packages into Lambda layer and not to break the illusion of complete demo delivered in single template. You’re welcome to use my layer published at
arn:aws:lambda:eu-west-1:017001121740:layer:pyjwt:1
or build you own with buildlayer.sh. Note that prebuild layer is available only at eu-west-1. If you plan to build the stack somewhere else you must provide your own layer as function can’t use layer from remote regions.
NOTE: For this demo you need PyJWT. I found it very easy to mix it with JTW. These modules don’t really work together well and are not compatible. Here is the stackoverflow thread about the subject.
It’s Demo Time
After you have created a stack from template you will find URL for ALB from stack outputs.
This will take you first to Congito login screen and then, after sucessful login, to the loadbalancer backend which in this demo shows info available from JWT token.
Beautiful thing here is, I didn’t have to spend any time in building/hosting authentication service, including signup, signin, email verification, password recovery etc. but all is taken care by Cognito. Thing that wasn’t shonw in this demo was customzing Cognito UI. For real application you would want Cognito UI match with the application style.