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:
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!
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