Blog

AWS SAM API with Cognito

It’s official! AWS has decided that Lambdas are our hammer, and we’re all wandering around looking for nails. AWS Lambda is a serverless computer service that lives in a container and runs in response to an event. AWS orchestrates that container for you and exposes it to the world through an API Gateway that integrates with an authentication layer.

It’s a compelling use case:

  • It’s cheap to run
  • Easy-ish to maintain
  • No infrastructure
  • You can run scalable code-as-a-function in the cloud.

Lots of tutorials exist to get a Hello World function running using various tools that are both coding and AWS UI related. These tutorials often leave out the ability to create a central API Gateway for a set of functions and leave out how to protect your API with a basic Authentication layer.

My goal is to show a Hello Whole Wide World example that includes some of these details.

What we are building

  • An API Gateway
  • A Cognito User Pool to restrict access to one of our functions
    • A Cognito User
  • A simple funcion that is NOT protected by our auth layer
  • A simple funcion that is protected by our created auth layer

Pre-requisites

Let’s Dive In

You can download the repo, set the needed variables (STACK_NAME, STACK_BUCKET, YOUR_EMAIL) and run make deploy to see this in action.

We want to be able to build from zero to stack. I have found that AWS Lambdas are a sensitive beast and will require continual iteration around subtle details. I cannot stress enough the need to have code that you can run repeatedly in order to step through these iterations methodically. Take it from someone who has lost days of work to this phenomenon that it is worth setting up code from the start.

UserPool / Client / User

UserPool:
Type: AWS::Cognito::UserPool
Properties:
AdminCreateUserConfig:
AllowAdminCreateUserOnly: false
UserPoolName: TestingUsers
UsernameAttributes:
- email
AutoVerifiedAttributes:
- email
Policies:
PasswordPolicy:
MinimumLength: 6
RequireLowercase: true
RequireNumbers: false
RequireSymbols: false
RequireUppercase: true
UserPoolTokenClient:
Type: AWS::Cognito::UserPoolClient
Properties:
UserPoolId: !Ref UserPool
GenerateSecret: false
ExplicitAuthFlows:
- USER_PASSWORD_AUTH

We create a user pool and a user pool client.

  • The pool is the abstract collection of users and their info.
  • The client is the ability to login using the SDK or the CLI.

The user pool client provides the ability to login using the Amplify SDK or the AWS CLI. You may need additional clients (We don’t yet have Oauth) and additional properties, but this is a working minimum set that works. Take a look at the Cloudformation Reference Docs for more details. For logging in, we will be using the AWS CLI.

UserPoolUser:
Type: AWS::Cognito::UserPoolUser
Properties:
DesiredDeliveryMediums:
- EMAIL
Username: !Ref YourEmail
UserPoolId: !Ref UserPool

We also create your first user using a set environment variable YOUR_EMAIL. This will send your email a temporary password on stack creation. Below are instructions for how we will login (spoiler, it’s with the CLI).

API

Technically we don’t need this. SAM (Serverless Application Model) will create one for us. But then when you have two functions, you have two full APIs. We can do better.

ServiceApi:
DependsOn: UserPool
Type: AWS::Serverless::Api
Properties:
Name: ServiceApi
StageName: !Ref Version
Cors:
AllowMethods: "'*'"
AllowHeaders: "'*'"
AllowOrigin: "'*'"
Auth:
Authorizers:
CognitoAuthorizer:
UserPoolArn: !GetAtt "UserPool.Arn"

Once created, we use the API ID to attach the created functions in one logical group. I have also set cors headers leaving this wide open. Because this opens the API up to some cross scripting attacks and allows incoming traffic from everywhere, you should be careful doing this. Now when we create our functions we can pool them together under this API and have a more organized microservice instead of a collection of functions.

Functions

Pretty basic declaration. Here is the Unauthenticated Function

OpenLambdaFunction:
Type: AWS::Serverless::Function
Properties:
Description: Handles the basic request with no need for authentication
Runtime: go1.x
Handler: ./dist/open
Events:
Get:
Type: Api
Properties:
Path: /open
RestApiId: !Ref ServiceApi
Method: GET

And the Authenticated Endpoint Function. They look very similar, but I wanted different code to handle each. There are good use cases for both merging into events vs separate functions.

LambdaFunction:
Type: AWS::Serverless::Function
Properties:
Description: Handles the basic request
Runtime: go1.x
Handler: ./dist/authenticated
Events:
Get:
Type: Api
Properties:
Path: /
RestApiId: !Ref ServiceApi
Method: GET
Auth:
Authorizer: CognitoAuthorizer

The Function specifies the API Gateway to file under, the Authorizer to use, and the path / method to respond to.

Code

This is arguably the simplest part. Just send back a 200. This code is basically the same for both, but with payload content tweaks.

return events.APIGatewayProxyResponse{
StatusCode: 200,
Body:
"{"message":"Marshalling a return body is a problem for another day. But the request was successful","structure":"See this is actually not an error"}",
// This is important as part of the CORS config.
// Again you should know the security implications of CORS before implementing this
Headers: map[string]string{
"Content-Type":
"application/json",
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "*",
"Access-Control-Allow-Headers": "*",
}, }, nil

Build and Deploy

Since we are using GO, we need a build and compile process. I am using a basic Makefile to compile using GO and run the AWS SAM Cli commands.

You will need to set the following variables:

  • STACK_NAME
    • What to call the cloudformation Stack
  • STACK_BUCKET
    • SAM uploads your compiled code resources to a bucket. I’m leaving it to you to create a bucket and set this environment variable
  • YOUR_EMAIL
    • Put in a valid email address for the first user to create. You can do this through the AWS portal, but the focus is again on a full code auth example.

Once set, run make deploy. Assuming you have access to your AWS environment, you’ll see the build process compile the code, upload it to the bucket while transpiling the SAM template into an AWS Cloudformation template, and deploying the stack. This will work for updates as well. You will want to view the outputs from the stack creation in order to get the ids needed for login, and the API url to call. I use the web portal for this purpose, but you can also access the output with the CLI.

I have an API, now what?

Let’s call the open endpoint.

curl https://{{Your API ID}.execute-api.us-east-1.amazonaws.com/v1/open
{ message: "This endpoint does not require any authentication", structure: "This field was added just to prove it's not an error" }

This works! Now let’s call the Authorized endpoint

curl https://{{Your API ID}.execute-api.us-east-1.amazonaws.com/v1
{"message":"Unauthorized"}

Technically this is a good thing, but we can do better. We need to login. We created a token client that will respond to SDK / CLI requests to log in. This is arguably less secure, but allows us to login without additional infrastructure.

Login and get a token

We will use the AWS cli to login. The first login will require changing the password and follow a challenge workflow. To make this slightly less painful, I created a script you can call that will log in and run the password challenge response.

AUTH_CHALLENGE_SESSION=`aws cognito-idp initiate-auth
--auth-flow USER_PASSWORD_AUTH
--auth-parameters "USERNAME={{YOUR_EMAIL}},PASSWORD={{password from the email AWS sent you}}"
--client-id {{Token Client ID}}
--query "Session"
--output text` # Then respond to the challenge aws cognito-idp

admin-respond-to-auth-challenge
--user-pool-id $USERPOOLID
--client-id {{Token Client ID}}
--challenge-responses "NEW_PASSWORD=Testing1,USERNAME={{YOUR_EMAIL}}"
--challenge-name NEW_PASSWORD_REQUIRED
--session $AUTH_CHALLENGE_SESSION

To use this script, get the output values from your CloudFormation stack and run the following command:

./scripts/login_first.sh {{User Pool ID}} {{Token Client ID}} {{Your Email}} {{Password in the Email AWS sent you}}

This will change your password to ‘Testing1’ and log you in. You will get back a JSON Web Token or JWT token you can now use to finally call the damn API.

Bring it home

Now that we have the auth token, we can add it to the headers and call the

curl -H "Authorization: {{AUTH_TOKEN (The output from the login_first script)}}" https://{{Your API ID}}.execute-api.us-east-1.amazonaws.com/v1

// {"message":"Marshalling a return body is a problem for another day. But the request was successful","structure":"See this is actually not an error"}

What have we accomplished?

So we now went from zero to:

  • Cognito UserPool Authorizer
  • API Gateway where we can put multiple functions
  • A function that does not require authorization at path /open
  • A function that requires authorization at path /
    We can login using the AWS CLI / the login script ./scripts/login.sh {{UserPool Client ID}} {{Your Email}} Testing1 and add the output IdToken to our request in order to call our API.

Recommended Reading

Categories: Blog

Tags: , ,

Jeff Adams
26 Jun, 2019