« Back
in AWS serverless Golang read.
Serverless Framework Authentication and Logging with AWS Cognito

Serverless Framework Authentication and Logging with AWS Cognito.

Over the past few days, I've spent some time re-assessing the Serverless Framework to see if it can help bootstrap new ventures in a faster way.

The Previous Venture

In my last venture for a major Australian bank, we used Auth0 for authentication and a series of Golang microservices on a Kubernetes cluster. This workflow was extremely fast and very pleasureable to work in. But then I thought, we keep re-doing these integrations and spinning up a lot of infrastructure; We used Rancher, Terraform + Helm Charts on AWS. This took a lot of maintenance such as:

  • Managing AMIs and security updates
  • Managing Rancher which was supposed to manage the cluster
  • Writing and maintaining Helm Charts as we added things in like Cron pods or adding Vault instead of using Kubernetes secrets
  • Restarting nodes when EC2 instances just decided to hang

From this though, I built a Golang Microservice Yeoman Generator to help bring up microservices faster. It would use protocol buffers and hook up GRPC endpoints.

Experimenting With Serverless

So now I've gone back to look at less management again for the next venture. Enter Serverless a second time. The last time I looked at Serverless(framework), it wasn't very mature. Now, I can see a lot of potential, especially when it comes prototyping and reducing costs.

The problem I wanted to tackle was authentication and logging. Additionally, I wanted to do it in Golang so that I had a fallback. Basically if Serverless didn't work out, I can at least I can move the code from a decomposed state, back into microservices onto Kubernetes. This is much easier to do than starting with a monolith and decomposing it into functions. The other optional fallback was to install Kubeless which will give Kubernetes the ability to run functions. But that's for another day.

I chose AWS Lambda over Google Cloud Functions because AWS seemed more mature in this space. Lambda had more event triggers, it had other authorization functions, it can be written in more languages including Go. GCP only had Node and the authorization had to be done in the function itself. You also get all the benefits of Golang. Small, compiled binaries that run fast.

Logging First

Part of the learnings was that log aggregation should be tackled first. The general process is this:

  • create a Serverless project with a single function (the log forwarder/aggregator) to your integration of choice e.g. Splunk, Logz.io, Sumo Logic etc.
  • Deploy the function without API Gateway as a standalone function
  • Grab the ARN of the function

Then using the ARN of that function, we put it into another Serverless project using the serverless-log-forwarding plugin.

The reason behind this is that every function gets a Cloudwatch Log Group created so we can see the logs for the individual function calls. What we want to do is subscribe future functions to this log forwarder log group, so that it ships the content onto our integration of choice. That way we get richer searches, reporting, and alerts. Keep in mind though that if you want to keep the correlation ID across all the functions, you need to pass on the X-Amzn-Trace-Id header.

I've put together a Golang Log forwarder boilerplate on GitHub with instructions so you can see how it works. Keep in mind though that people like Logz.io already have a log forwarder function implemented for you. But you can wrap it in a Serverless framework for easier deployment.

The Auth Application

Update July 23, 2018: Read below for using User Pools and Identity Pools. However, I found that to keep flexibility of auth providers and to have fine grained permissions without too much lock-in to how AWS does things, I skipped Identity pools and used a custom authorizer function with just the User Pools. You can see an example here: https://github.com/serinth/serverless-cognito-auth

Okay so that was the forwarder, now let's look at an actual Go Serverless application. Start by creating a boilerplate app with Dep as the dependency manager. We can do that with:

serverless create -t aws-go-dep  

We also need the plugin above so that our function logs will go to our forwarder lambda function.

npm init  

Fill out the details. Then:

npm install --save-dev serverless-log-forwarding  

Now let's take a look at the main serverless.yml file:

service: myService

plugins:  
  - serverless-log-forwarding

custom:  
  logForwarding:
    destinationARN: <ARN OF FORWARDER>
    filterPattern: "-\"RequestId: \""
  stage: ${opt:stage, self:provider.stage}
  appName: myAppName

provider:  
  name: aws
  runtime: go1.x
  stage: dev
  region: ap-southeast-2
  memorySize: 128
  tags:
    appName: ${self:custom.appName}
    stage: ${self:custom.stage}
    owner: tony.truong

package:  
 exclude:
   - ./**
 include:
   - ./bin/**

functions:  
  hello:
    handler: bin/hello
    events:
      - http:
          path: hello
          method: get
          cors: true
          authorizer: aws_iam
  world:
    handler: bin/world
    events:
      - http:
          path: world
          method: get
          cors: true
  postConfirmation:
    handler: bin/postConfirmation

# Create our resources with separate CloudFormation templates
resources:  
  # Cognito
  - ${file(resources/cognito-user-pool.yml)}
  - ${file(resources/cognito-identity-pool.yml)}

There's a few things to note here:

  • The plugin reference. When we deploy, it will look at that plugin on the dev machine and know to attach to the log forwarder with the ARN from earlier
  • The custom field can be filled out with anything. We take optional command line arguments so we can override the default dev environment
  • Provider options will apply those configurations to all of our functions such as tagging so that we know where our resources came from
  • authorizer: aws_iam will use the identity pool (more later) so that only logged in users can invoke the hello function
  • ${file... allows us to have Cloudformation-like yaml files external to the main serverless.yml file so that it is more readable

Cognito User Pool and Identity Pool Resource

These are the yaml files as an example of how to create the two different pools.

Cognito User Pool - Contains user information. Logs users in with JWTs that have claims attached and has Group management (which we won't use here). Users signing up will have an entry into the User Pool on the AWS Console. The resources/cognito-user-pool.yml is an example of provisioning us a user pool if one doesn't exist already.

cognito-user-pool.yml:

Resources:  
  CognitoUserPoolMyPool:
    Type: AWS::Cognito::UserPool
    Properties:
      # Generate a name based on the stage
      UserPoolName: ${self:custom.appName}-${self:custom.stage}-user-pool
      # Set email as an alias
      UsernameAttributes:
        - email
      AutoVerifiedAttributes:
        - email

  CognitoUserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      # Generate an app client name based on the stage
      ClientName: ${self:custom.appName}-${self:custom.stage}-user-pool-client
      UserPoolId:
        Ref: CognitoUserPoolMyPool
      ExplicitAuthFlows:
        - ADMIN_NO_SRP_AUTH
      GenerateSecret: false

# Print out the Id of the User Pool that is created
Outputs:  
  UserPoolId:
    Value:
      Ref: CognitoUserPoolMyPool

  UserPoolClientId:
    Value:
      Ref: CognitoUserPoolClient

Cognito Identity Pool - Is what we use to allow the client to invoke AWS resources (our secure lambda functions, S3 etc).

cognito-identity-pool.yml

Resources:  
  # The federated identity for our user pool to auth with
  CognitoIdentityPool:
    Type: AWS::Cognito::IdentityPool
    Properties:
      # Generate a name based on the stage
      IdentityPoolName: GoAuth${self:custom.stage}IdentityPool
      # Don't allow unathenticated users
      AllowUnauthenticatedIdentities: false
      # Link to our User Pool
      CognitoIdentityProviders:
        - ClientId:
            Ref: CognitoUserPoolClient
          ProviderName:
            Fn::GetAtt: [ "CognitoUserPoolMyPool", "ProviderName" ]

  # IAM roles
  CognitoIdentityPoolRoles:
    Type: AWS::Cognito::IdentityPoolRoleAttachment
    Properties:
      IdentityPoolId:
        Ref: CognitoIdentityPool
      Roles:
        authenticated:
          Fn::GetAtt: [CognitoAuthRole, Arn]

  # IAM role used for authenticated users
  CognitoAuthRole:
    Type: AWS::IAM::Role
    Properties:
      Path: /
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: 'Allow'
            Principal:
              Federated: 'cognito-identity.amazonaws.com'
            Action:
              - 'sts:AssumeRoleWithWebIdentity'
            Condition:
              StringEquals:
                'cognito-identity.amazonaws.com:aud':
                  Ref: CognitoIdentityPool
              'ForAnyValue:StringLike':
                'cognito-identity.amazonaws.com:amr': authenticated
      Policies:
        - PolicyName: 'CognitoAuthorizedPolicy'
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: 'Allow'
                Action:
                  - 'mobileanalytics:PutEvents'
                  - 'cognito-sync:*'
                  - 'cognito-identity:*'
                Resource: '*'

              # Allow users to invoke our API
              - Effect: 'Allow'
                Action:
                  - 'execute-api:Invoke'
                Resource:
                  Fn::Join:
                    - ''
                    -
                      - 'arn:aws:execute-api:'
                      - Ref: AWS::Region
                      - ':'
                      - Ref: AWS::AccountId
                      - ':'
                      - Ref: ApiGatewayRestApi
                      - '/*'
# Print out the Id of the Identity Pool that is created
Outputs:  
  IdentityPoolId:
    Value:
      Ref: CognitoIdentityPool

Once a user has authorized with the User Pool, the User Pool will go ask the Identity pool for temporary credentials and pass that back to the front end. The front end will use that session token for invoking our secured lambda functions. That session token has taken the AWS role: CognitoAuthRole in the identity pool file. Which has permissions to invoke our lambda functions.

I could never get Serverless framework to automatically hook into the post confirmation event to the User Pool. There's a yaml syntax to add it but it never worked for me. Even when re-creating the pool, trying an ARN instead of the pool name, referencing the pool using Ref:. I think this will be worked out in the future on the framework. So in order to tie that event with the lambda function, you have to do it manually in the console for now.

One big thing to understand about this though is that it does not give you fine grained permissions in your application. If you wanted to deal with roles and teams that can access each other's resources, you need to manage that yourself.

In order to do that, you'll need to store the details of the user in a custom table and update it later. We can use Cognito to verify emails and mobile numbers for us. When that happens, we can hook into the PostConfirmation event to retrieve the user details. That is the postConfirmation function. Which is why you don't see an http endpoint for it when deploying.

I initially went down the path of using the Identity pool and tried to get the user details in the lambda function. That's not necessary, we only need the unique identifier of the user after they've logged in. Keep track of who the user is on post confirmation and maintain permissions / relationships that way. Identity pool is not meant for group and team management at a fine level. If you do know of a better way of managing this, please email me and let me know. I'll update this post.

Lambda Functions in Go

An example of a lambda function in Go that hooks off of API Gateway looks like this:

package main

import (  
    "fmt"

    "github.com/aws/aws-lambda-go/events"
    "github.com/aws/aws-lambda-go/lambda"
)

func Handler(request events.APIGatewayProxyRequest) (events.APIGatewayProxyResponse, error) {

    fmt.Printf("events.APIGatewayProxyRequestContext.Identity: %#v \n", request.RequestContext)

    fmt.Printf("Headers: %#v \n", request.Headers)

    return events.APIGatewayProxyResponse{
        Body:       "Hello called, authenticated",
        StatusCode: 200,
        Headers: map[string]string{
            "Access-Control-Allow-Origin":      "*",
            "Access-Control-Allow-Credentials": "true",
        },
    }, nil
}

func main() {  
    lambda.Start(Handler)
}

Yet the one for the the post confirmation hook is a bit inconsistent and looks more like this:

package main

import (  
"fmt"

"github.com/aws/aws-lambda-go/events"
"github.com/aws/aws-lambda-go/lambda"
)

func Handler(event events.CognitoEventUserPoolsPostConfirmation) (events.CognitoEventUserPoolsPostConfirmation, error) {

    fmt.Printf("User Attributes: %#v \n", event)

    return event, nil
}

func main() {  
    lambda.Start(Handler)
}

Lambda pitches reduced costs and I think this is true -- at least initially. At scale though, I actually think it's easier to run a cluster where the functions are being invoked constantly and use very little memory. You also don't have the notorious warm up time to deal with.

To tie it all together and start testing everything, you can use the AWS provided lib: https://github.com/aws-amplify/amplify-js for the front end.

Overall, I think it's worth a try for things like personal projects where you don't know if your ideas are going to take off so you don't have to pay for much invocation. Then pay for nothing if people aren't using it. I'm willing to jump through a little bit more hoops and have a bit of vendor lock in even if it means I can penny pinch =].

comments powered by Disqus