Backend for your JAMStack application: Part 1 Setting up Cognito

Backend for your JAMStack application: Part 1 Setting up Cognito

ยท

9 min read

You dream of building the next big thing ๐Ÿฆ„.

You've built an app and your mom loves it! But you have a problem: you're not a backend engineer and never actually built one. You might be even thinking of hosting a nodejs server and managing all the resources yourself (please, don't... ๐Ÿ˜ข). Maybe you've heard of firebase or even AWS but don't know where and how to start? EC2 or Digital Ocean?

Well then look no further, because this series is for you.

I have been in this exact situation and I can tell you that there is a much easier and cheaper solution to your problems: Amazon Web Services + Serverless framework.

These two are a match made in heaven and will unlock your superpowers! (And not the lame ones like flying, or xray vision, etc...)

More like awesome superpowers that allow you to flexibly build scalable backends for your apps without tradeoffs. Superpowers that allow you to build an event listener for a service, such as stripe, and post a notification to your slack channel when you lose a paying customer (to impress your colleagues ofc ๐Ÿ˜ฑ) WITHOUT THE STRESS OF MANAGING SERVERS.

TL;DR get the full repo here

Why this tutorial series?

When I initially started my aws serverless journey, I spent many nights crying in the bathroom (for unrelated reasons) and dreamt of becoming a goat herder ๐Ÿ.

The documentation provided by AWS was confusing and reminded me of the movie Tenet (2 hours in and I still had no clue wtf I was reading). I had to scrounge for resources from all over the internet and most of the times stuff just didn't work as advertised or reminded me of this helpful guide:

Pasted image 20211021141806.png

Another thing that amazon's documentation heavily relies on: using their web console and clicking around in it. You don't have a full overview of your services and it doesn't allow you to easily replicate your backend for different environments or even projects.

Serverless framework to the rescue!

With this series, I'm going to show you how to build a backend that

  • satisfies most use cases
  • can power your JAMStack app
  • is scalable
  • is easily replicatable

so you could get on with your life...

Without further ado, let's dive head first in to this tutorial series.

Part 1: Authentication & Cognito

The first part of any modern application is handling the storage of users and their data.

There are other services to do that, such as auth0 or okta. But I never understood okta's confusing pricing model and auth0 was recently acquired by okta which caused a small uproar ๐Ÿคท๐Ÿปโ€โ™‚๏ธ.

Neither of them provide a way to JUST build a god damn back-end and instead you have to build another layer of ducktape, glue and tears just to glue all this mess together in order to make it work. (Yes you are the lonely maintainer of it too...)

Neither of them provide an easy way to store your infrastructure as code.

And the best part: IT'S FREE. (Unless you cross 50k MAU-s)

That's why I suggest you use cognito and I think this blog post also does a great job illustrating why you should go with cognito.

You will need

  • Npm (At the time of writing this post I'm using 7.15.0)
  • An AWS IAM User with
    • An access key for programmatic access
    • A password, to access AWS management console (just in case)

To create an aws user check out creating IAM users (paragraph console)

Installing the necessary dependencies

Start off by creating a new npm package

npm init -y

And installing the serverless framework:

npm i -D serverless

The serverless framework version used by this tutorial is 2.63.0

Setting up the serverless yml

Let's create a standalone service for cognito. It helps with keeping deployment times low, separating services in to manageable chunks and decreases the blast radius, in case of unauthorized access.

So create a new file in your project called serverless.yml and add these lines:

service: cognito-auth

custom: 
  # Either the stage is specified via commandline, through provider.stage or defaults to "dev"
  stage: ${opt:stage, self:provider.stage, 'dev'} 

provider:
  name: aws
  # By default it's nodejs12.x, but Hey, we're bleeding edge...
  runtime: nodejs14.x 
  # I'm situated in europe
  region: eu-central-1
  lambdaHashingVersion: 20201221

Remember to specify your service region, I'm located in central Europe, so I specified eu-central-1 (the default is us-east-1)

User pools

When you hear the words "user pool", think "user directory" or "user list". This is essentially what a cognito user pool is: a place to store your app's users.

"Cool! I will store everything I know about my users here: their profile picture, home address, their subscription status. Basically everything I know about this person. Seems like a great place for it. Right?"

NO!!! DON'T

The data in the user pool, is used to generate TOKENS for your users.

These tokens should be small in size and NOT contain sensitive information such as payment info, subscription status or even worse passwords. They will be constantly sent over the internet and the smaller and more ambiguous they are, the better (btw tokens are a bit borked on AWS... more on that in a later post... )

Besides, there are MUCH better places to store user's data such as a DynamoDB table or an S3 bucket. I will update this series in the future with examples on how to store data in a serverless app.

Let's actually create the user pool. Update your serverless yml file to this:

service: cognito-auth

custom: 
  # Either the stage is specified via commandline, through provider.stage or defaults to "dev"
  stage: ${opt:stage, self:provider.stage, 'dev'} 

provider:
  name: aws
  # By default it's nodejs12.x, but Hey, we're bleeding edge...
  runtime: nodejs14.x 
  # I'm situated in europe
  region: eu-central-1
  lambdaHashingVersion: 20201221

resources:
  Resources:
    # The name that you will use to refer to this resource
    # in this YAML file.
    CognitoUserPool:
      Type: AWS::Cognito::UserPool
      Properties:
        # The actual name of the user pool 
        UserPoolName: ${self:custom.stage}-user-pool
        # The username that users will have to use to login
        UsernameAttributes:
          - email
        # What kind of attributes should be automatically verified
        AutoVerifiedAttributes:
          - email

We have now defined a very basic user pool with only 3 properties, but check out the official documentation for the full list of properties to enable MFA or enforce password length amongs other things.

NB: Updating some of these properties on an existing user pool WILL DELETE ALL USERS!!! So be sure to refer to the official docs before messing with production deployments.

User pool client

"What is an user pool client?" you might ask. The official documentation has a good explanation for it, but if You still struggle to understand why would you need one, let me ask you some questions:

You have an user pool. Would you blindly trust EVERYONE on the internet to directly add users to it? (I wouldn't...)

Let's say you want your users that login from your web app, to solve a captcha. How would you handle this?

You want users who login from the webapp to remain logged in for 30 days, and who login from your mobile app to stay logged in indefinetly.

This is where the user pool client comes in: It allows your users to call unauthenticated API operations and you're the one who makes the rules. This is the first gate to your kingdom. The door to YOUR nightclub!

borat-dance.gif Disco dancing! Is very nice.

So Let's define our CognitoUserPoolWebClient

resources:
  Resources:
    CognitoUserPool:...

    CognitoUserPoolWebClient:
      Type: AWS::Cognito::UserPoolClient
      Properties: 
        UserPoolId: !Ref CognitoUserPool
        TokenValidityUnits: # What unit each token validity is specified in
          AccessToken: hours # default
          IdToken: hours # default
          RefreshToken: days # default
        AccessTokenValidity: 1
        IdTokenValidity: 1
        RefreshTokenValidity: 30 
        ClientName: ${self:custom.stage}-${self:service}-web-client
        ExplicitAuthFlows: 
          - ALLOW_USER_SRP_AUTH # default by aws-amplify
          - ALLOW_REFRESH_TOKEN_AUTH # Allows user tokens to be refreshed
        GenerateSecret: false
        PreventUserExistenceErrors: ENABLED
        SupportedIdentityProviders: 
          - COGNITO

As the name implies, this is going to be our "web" client. It may look daunting, but most of the properties are actually optional. They also have self explanatory names. Feel free to refer back to the official UserPoolClient documentation

Identity pools

The moment You've been waiting for! Squid game season 2 Creating an identity pool ๐Ÿฅณ.

But this is where it gets hairy ๐Ÿ’‡โ€โ™€๏ธ.

The first step is to patch together a ProviderName. Let's export a variable called CognitoIdentityPoolProviderName:

resources:
  Resources: ...
  Outputs:
    CognitoIdentityPoolProviderName:
      # ProviderName has a format like 'cognito-idp.{region}.amazonaws.com/{UserPoolId}'
      Value: !Join [ "", ["cognito-idp.${self:provider.region}.amazonaws.com", "/", !Ref CognitoUserPool]]
      Export:
        Name: ${self:custom.stage}-${self:service}-ExtCognitoIdentityPoolProviderName

What is a provider name? Well According to the official documentation, it's "The provider name for an Amazon Cognito user pool". It's super obvious vague. Right?

The most helpful clue is that it's a constant string that looks like

cognito-idp.us-east-2.amazonaws.com/us-east-2_123456789

THIS is what we're piecing together under the CognitoIdentiyPoolProvider value.

I don't know why the ๐Ÿฆ† is the documentation so vague about it This enables you to work with multiple identity providers in your frontend.

Otherwise, if you had multiple identity pools in use in your front end, how would you refer to each one?

With this out of the way, lets define the actual identity pool:

resources:
  Resources:
    CognitoUserPoolWebClient:...    

    CognitoIdentityPool:
      Type: AWS::Cognito::IdentityPool
      Properties: 
        # https://docs.aws.amazon.com/cognito/latest/developerguide/authentication-flow.html
        AllowClassicFlow: false
        AllowUnauthenticatedIdentities: true
        CognitoIdentityProviders: 
          - ClientId: !Ref CognitoUserPoolWebClient
            ProviderName: ${self:resources.Outputs.CognitoIdentityPoolProviderName.Value}
            ServerSideTokenCheck: true
        IdentityPoolName: ${self:custom.stage}-${self:service}

IAM Roles

The final stretch.

Let's define two IAM roles under our resources:

resources:
  Resources:

    CognitoIdentityPool:...

    AuthenticatedRole:
      Type: AWS::IAM::Role
      Properties: 
        AssumeRolePolicyDocument:
          Version: "2012-10-17"
          Statement:
            - Sid: "AllowCognitoAssumeAuthenticatedRole"
              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
        RoleName: ${self:custom.stage}-${self:service}-authenticated-role

    UnauthenticatedRole:
      Type: AWS::IAM::Role
      Properties:
        AssumeRolePolicyDocument:
          Version: "2012-10-17"
          Statement:
            - Sid: "AllowCognitoAssumeUnauthenticatedRole"
              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: unauthenticated
        RoleName: ${self:custom.stage}-${self:service}-unauthenticated-role

These two roles will be assigned to users by the identity pool.

They don't do anything cool right now and are basically placeholders. I'll get to them in a later post (this blog post is beginning to feel a lot like Tenet ๐Ÿ˜ฌ)

But who assigns these roles? Well, for that we have to add ONE LAST resource. The IdentityPoolRoleAttachment:

resources:
  Resources:
    # Manages the roles for the identity pool
    CognitoIdentityPoolRoleAttachment: 
      Type: AWS::Cognito::IdentityPoolRoleAttachment
      Properties: 
        IdentityPoolId: !Ref CognitoIdentityPool
        RoleMappings: 
          "userpool":
            AmbiguousRoleResolution: AuthenticatedRole
            IdentityProvider: !Join [ "", ["${self:resources.Outputs.CognitoIdentityPoolProviderName.Value}", ":", !Ref CognitoUserPoolWebClient]]
            Type: Token
        Roles: 
          "authenticated": !GetAtt AuthenticatedRole.Arn 
          "unauthenticated": !GetAtt UnauthenticatedRole.Arn

Think of the IdentityPoolRoleAttachment as a moderator. It's attached to a cognito identity pool and it defines how to resolve user roles and what are the authenticated and unauthenticated roles.

After all this you can deploy your service by running

sls deploy

Outro

So what did we just build?

A very basic cognito auth service that doesn't do much on it's own. BUT it serves as a fundamental starting point for any modern application.

I know it looks like a lot of work and code to get this working, but think about it in another way: If you rolled your own solution to store users, how long would it take? How much code is needed to securely handle user login and determine what an user can access? How much effort would you have to spend to scale your solution?

My intention is to expand on this example in the future and teach you everything about cognito.

What did you think? What concepts were hard to grasp? What would you like to see next?

Let me know in the comments.

Also check out the full repository here

ย