Using Auth0 to secure Architect application routes

avatar
Simon MacDonald
January 20, 2022

lots of wires Photo by Franck on Unsplash

Overview

In this blog post, we are going to explore using Architect, Auth0, and DynamoDB to build a ToDo app. Each user will only be able to see their own todos, so we will need to implement authentication, authorization, and a database.

This article will cover:

The full source code of the ToDo app code can be found here.

Prerequisites

This article does not assume prior experience with any of these technologies. However, you will need Node.js installed to follow along. In addition, plan to have accounts for the managed services in the section below (Auth0 and Begin). Both are free as of this writing and don’t require a credit card.

Tech Stack

Architect is a simple tool to build and deliver powerful cloud function-based web apps and APIs.

Auth0 is an authentication and authorization solution that makes managing users and securing applications a breeze. It is an extremely battle-tested and mature solution for auth.

DynamoDB is a NoSQL database that is designed to be highly available and performant.

Auth0

The first thing we need to do is sign up for a free account with Auth0. Once at the dashboard, we need to create a new Tenant for our project.

A tenant is a way of isolating our users and settings from other applications we have with Auth0.

Click the name of your account in the top left, and then select Create tenant from the drop down.

Create tenant from Auth0 dashboard

Give your tenant a unique Domain, set the Region closest to you, and leave the Environment Tag set to Development.

Create tenant from Auth0 dashboard

In a production application, you want your region to be as close as possible to the majority of your users.

Next, we want to create an Application. Select Applications > Applications from the sidebar menu, and click + Create Application. Give it a name and select Regular Web Applications. Click Create.

Create tenant from Auth0 dashboard

From the application’s page, you are redirected to select the Settings tab and scroll down to the Application URIs section.

Add the following:

If you are running the app on a different port, you can change the port number in the URL.

Go to Advanced Settings > OAuth and confirm the JSON Web Token (JWT) Signature Algorithm is set to RS256 and that OIDC Conformant is enabled. Then save your changes.

We are now done with configuring Auth0. On to building our app!

Architect

The fastest way to create a Architect application is to run the following npm command:

npm init @architect todo-auth0

To make this simple, we’ll add some HTML right to our function’s response, but it’s possible to send static HTML with Architect. Replace the contents of src/http/get-index/index.js with:

// src/http/get-index/index.js
exports.handler = async function http (req) {
  return {
    statusCode: 200,
    headers: {
      'cache-control': 'no-cache, no-store, must-revalidate, max-age=0, s-maxage=0',
      'content-type': 'text/html; charset=utf8'
    },
    body: `
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Architect</title>
  <style>
     * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; } .max-width-320 { max-width: 20rem; } .margin-left-8 { margin-left: 0.5rem; } .margin-bottom-16 { margin-bottom: 1rem; } .margin-bottom-8 { margin-bottom: 0.5rem; } .padding-32 { padding: 2rem; } .color-grey { color: #333; } .color-black-link:hover { color: black; }
  </style>
</head>
<body class="padding-32">
  <h1 class="margin-bottom-16">
    Architect ToDo Auth0
  </h1>
</body>
</html>
`
  }
}

Run the project in Development mode:

cd todo-auth0
npm start

And confirm it is working at http://localhost:3333.

Authentication

Let’s add a couple of routes to our application to handle authentication. The app.arc manifest is a simple config format that controls what resources are created for local development and deployed in the cloud. Open the app.arc file and add two new lines to the bottom for our callback and logout routes.

@app
todo-auth0

@http
get /
get /callback
get /logout

Then quit the development server and run the following command to generate the callback and logout routes:

arc init

You will notice this create a two new folders in the src/http directory, get-callback and get-logout which will be used to handle the /callback and logout routes.

Create a .env file in your root project folder and add:

AUTH0_BASE_URL=http://localhost:3333
AUTH0_ISSUER_BASE_URL=https://<name-of-your-tenant>.<region-you-selected>.auth0.com
AUTH0_CLIENT_ID=get-from-auth0-dashboard
AUTH0_CLIENT_SECRET=get-from-auth0-dashboard

AUTH0_CLIENT_ID and AUTH0_CLIENT_SECRET can be found at Applications > Settings > Basic Information in the Auth0 Dashboard.

You will need to quit the development server and re-run the npm start command anytime new environment variables are added to the .env file

Let’s update our src/http/get-index/index.js file to add the ability to log in and out:

// src/http/get-index/index.js
const arc = require('@architect/functions')

exports.handler = arc.http.async(http)

async function http (req) {
  const {
    AUTH0_BASE_URL: baseUrl,
    AUTH0_ISSUER_BASE_URL: issuerBaseUrl,
    AUTH0_CLIENT_ID: clientId
  } = process.env

  const loginUrl=`${issuerBaseUrl}/authorize?response_type=code&client_id=${clientId}&redirect_uri=${baseUrl}/callback&scope=openid%20profile`

  let session = await arc.http.session.read(req)

  return {
    statusCode: 200,
    headers: {
      'cache-control': 'no-cache, no-store, must-revalidate, max-age=0, s-maxage=0',
      'content-type': 'text/html; charset=utf8'
    },
    body: `
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
     * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; } .max-width-320 { max-width: 20rem; } .margin-left-8 { margin-left: 0.5rem; } .margin-bottom-16 { margin-bottom: 1rem; } .margin-bottom-8 { margin-bottom: 0.5rem; } .padding-32 { padding: 2rem; } .color-grey { color: #333; } .color-black-link:hover { color: black; }
  </style>
</head>
<body class="padding-32">
  <h1 class="margin-bottom-16">
    Architect ToDo Auth0
  </h1>
  <div class="margin-bottom-16">
  ${session.name ? `Welcome ${session.name}!` : ''} ${!session.token ?  `<a href="${loginUrl}">Login</a>` : `<a href="/logout">Logout</a>`}
  </div>
</body>
</html>
`
  }
}

Clicking the login link will redirect you to the Auth0 login page where you will be asked to grant access to your application. Once you grant access, Auth0 will re-direct you to the applications /callback route where the authorization code will be included as a URL query parameter.

Once we have the authorization code we will need to exchange it for an access token and store the access token in the session. We’ll do this by making a request to the Auth0 /oauth/token endpoint with the authorization code and the client ID and client secret. We’ll await the response to this API call and decode the response body to get which will look like this:

{
  access_token: 'ey…',
  id_token: 'ey…',
  scope: 'openid profile',
  expires_in: 86400,
  token_type: 'Bearer'
}

We are going to decode the id_token to get the user’s name and nickname which we will user later on in the application. The id_token is a JSON Web Token (JWT) that contains information about the user. You can read more about JWT’s, and even decode them here.

Consequently, we now need to update the src/http/get-callback/index.js file to handle the /callback route:

// src/http/get-callback/index.js
const arc = require('@architect/functions')
const fetch = require('node-fetch')
const jwt_decode = require('jwt-decode')

exports.handler = arc.http.async(http)

async function http (req) {
  const {
    AUTH0_BASE_URL: baseUrl,
    AUTH0_ISSUER_BASE_URL: issuerBaseUrl,
    AUTH0_CLIENT_ID: clientId,
    AUTH0_CLIENT_SECRET: clientSecret
  } = process.env

  const params = new URLSearchParams()
  params.append('grant_type', 'authorization_code')
  params.append('code', req.query.code)
  params.append('client_id', clientId)
  params.append('client_secret', clientSecret)
  params.append('redirect_uri', `${baseUrl}/callback`)

  const response = await fetch(`${issuerBaseUrl}/oauth/token`, {
    method: 'post',
    body: params,
    headers: {'Content-Type': 'application/x-www-form-urlencoded'}
  });
  const data = await response.json();
  const decoded = jwt_decode(data.id_token)

  return {
    session: {token: data, name: decoded.name, accountID: decoded.nickname},
    location: '/'
  }
}

After successfully exchanging the authorization code for an access token, we will redirect the user to the home page. We will also store the user’s name in the session as we will use this value to display the user’s name in the home page and use the account ID as a key when interacting with our DB.

When the user clicks the logout link, we will need to remove the access token from the session. To do this, we will need to update the src/http/get-logout/index.js file:

// src/http/get-logout/index.js
const arc = require('@architect/functions')
const fetch = require('node-fetch')

exports.handler = arc.http.async(http)

async function http (req) {
  const {
    AUTH0_BASE_URL: baseUrl,
    AUTH0_ISSUER_BASE_URL: issuerBaseUrl,
    AUTH0_CLIENT_ID: clientId
  } = process.env

  const logoutUrl=`${issuerBaseUrl}/v2/logout?client_id=${clientId}&returnTo=${baseUrl}`
  await fetch(logoutUrl);

  return {
    session: {},
    location: '/'
  }
}

Great, now we have a way to authorize and authenticate users but we need a way to store the user’s todo list but before we forget, let’s install the two new dependencies we introduced node-fetch and jwt-decode:

npm i node-fetch jwt-decode

You will need to quit the development server and re-run the npm start command anytime new npm packages are added to the project.

DynamoDB

DynamoDB is an incredibly powerful fully managed NoSQL database which we will use to store our todo list. Instead of jumping over to the AWS console and creating a new table, we will modify the app.arc file and run arc init to create a new table for us.

@app
todo-auth0

@http
get /
get /callback
get /logout

@tables
data
  scopeID *String
  dataID **String
  ttl TTL

You have now provisioned access to DynamoDB and you can start using it to store your todo list. No clicking around in various web consoles in order to setup your database.

That's it, that's the tweet

In order to make dealing with DynamoDB easier, we will add the @begin/data package to our project:

npm i @begin/data

When it comes to our data schema for our todo’s we’ll want it to look like this:

data:
    key:
        type: string
    todos:
        type: array
        todo:
            type: object
            properties:
                id:
                    type: string
                content:
                    type: string
                completed:
                    type: boolean
                createdAt:
                    type: integer
                updatedAt:
                    type: integer

Since DynamoDB is a key-value NoSQL database we will use the key to store the account ID and the todos to store the todo items. There’s no need to configure the schema anywhere as the document will be automatically created for us.

Now we can start using the @begin/data package to interact with our DynamoDB table. The src/shared folder is a special folder, Architect copies the contents of src/shared into all Lambdas at deploy time. Create a new file in the src/shared directory called crud.js.

// src/shared/crud.js
const { customAlphabet } = require('nanoid')
const nanoid = customAlphabet('1234567890BCDFGHJKLMNPQRSTVWXZ', 8) // no vowels
const data = require('@begin/data')

module.exports = { read, upsert }

async function read(accountID) {
    let todos = []
    if (accountID) {
        const response = await data.get({
            table: 'todos',
            key: accountID
        })
        todos = response ? response.items : []
        todos.sort((a, b) => a.created_at - b.created_at)
    }
    return todos
}

async function upsert(accountID, {
        id = nanoid(),
        content,
        completed = false,
        created_at = Date.now(),
        updated_at = Date.now()
    }) {

    if (accountID) {
        const todos = (await read(accountID)).filter(todo => todo.id !== id)
        await data.set({
            table: 'todos',
            key: accountID,
            items: [...todos, {
                content,
                completed: completed === "true" ? true : false,
                id,
                created_at,
                updated_at
            }]
        })
    }
}

For the read function we fetch the todo list from DynamoDB and sort it by the created_at property. If the user hasn’t created any todo’s yet, we’ll return an empty array.

The upsert function will create a new todo item and add it to the list if no id is provided. If an id is provided, we’ll update the existing todo item.

Now that we can read and write to our database it’s time to update our UI to display the todo list.

Shared Views

We are going to create a couple of components, called ToDos and ToDo, that will be used by our Lambda functions to display our todo list. We’ll create these files under src/views/components as the src/views folder is a special folder Architect copies the contents of src/views into all GET request Lambdas at deploy time.

The first component will be a list of todo items and we’ll call it src/views/components/todos.js.

// src/views/components/todos.js
const ToDo = require('./todo')

module.exports = async function ToDos ({items}) {
    return `
        <form action="/todos" method="post" class="margin-bottom-16">
            <input type="text" name="content" placeholder="Add a todo" />
            <input type="submit" value="Add" />
        </form>
        <ul class="margin-left-8 margin-bottom-16">
            ${items.length > 0 ? items.map(todo => ToDo(todo)).join('') : `Great job, you've completed all your todos`}
        </ul>
    `
}

The ToDos component will render a form and a list of todo items. The form will allow the user to add a new todo item. The rendering of each todo item will be handled by the ToDo component.

// src/views/components/todo.js
module.exports = function ToDo ({content, completed, id, created_at}) {
    return `
    <li>
        ${completed ? `<del>${content}</del>` : content}
        <form action="/todos/${id}" method="post" style="display: inline;">
            <input type="hidden" name="content" value="${content}" />
            <input type="hidden" name="created_at" value="${created_at}" />
            <input type="hidden" name="completed" value="${!completed}" />
            <input type="submit" value="${completed ? 'Undo' : 'Complete'}" />
        </form>
    </li>
`
}

Once we have created these components we will need to update our src/views/index.js file to render the todos.

// src/http/get-index/index.js
const arc = require('@architect/functions')
const { read } = require('@architect/shared/crud')
const ToDos = require('@architect/views/components/todos')

exports.handler = arc.http.async(http)

async function http (req) {
  const {
    AUTH0_BASE_URL: baseUrl,
    AUTH0_ISSUER_BASE_URL: issuerBaseUrl,
    AUTH0_CLIENT_ID: clientId
  } = process.env

  const loginUrl=`${issuerBaseUrl}/authorize?response_type=code&client_id=${clientId}&redirect_uri=${baseUrl}/callback&scope=openid%20profile`

  let session = await arc.http.session.read(req)

  let items = await read(session.accountID)

  return {
    statusCode: 200,
    headers: {
      'cache-control': 'no-cache, no-store, must-revalidate, max-age=0, s-maxage=0',
      'content-type': 'text/html; charset=utf8'
    },
    body: `
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
     * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; } .max-width-320 { max-width: 20rem; } .margin-left-8 { margin-left: 0.5rem; } .margin-bottom-16 { margin-bottom: 1rem; } .margin-bottom-8 { margin-bottom: 0.5rem; } .padding-32 { padding: 2rem; } .color-grey { color: #333; } .color-black-link:hover { color: black; }
  </style>
</head>
<body class="padding-32">
  <h1 class="margin-bottom-16">
    Architect ToDo Auth0
  </h1>
  <div class="margin-bottom-16">
  ${session.name ? `Welcome ${session.name}!` : ''} ${!session.token ?  `<a href="${loginUrl}">Login</a>` : `<a href="/logout">Logout</a>`}
  </div>
  ${session.token ? await ToDos({items}) : ''}
</body>
</html>
`
  }
}

Add ToDo

Great we can see the add todo form but trying to add a new todo item will fail. We’ll need to add new routes to handle the submission and completion of todo items.

Update Routes

Once again we open the app.arc file and add two new lines to the bottom of the @http section for the create post /todos and update post /todos/:id routes.

@app
todo-auth0

@http
get /
get /callback
get /logout
post /todos
post /todos/:id

@tables
data
  scopeID *String
  dataID **String
  ttl TTL

This will create src/http/post-todos/index.js and src/http/post-todos-000id/index.js files.

For src/http/post-todos/index.js replace the contents with the following to handle the submission of a new todo item:

// src/http/post-todos/index.js
const arc = require('@architect/functions')
const { upsert } = require('@architect/shared/crud')

exports.handler = arc.http.async(http)

async function http (req) {
  await upsert(req.session.accountID, req.body)

  return {
    location: `/`
  }
}

For src/http/post-todos-000id/index.js replace the contents with the following to handle the updating of an existing todo item:

// src/http/post-todos-000id/index.js
const arc = require('@architect/functions')
const { upsert } = require('@architect/shared/crud')

exports.handler = arc.http.async(http)

async function http (req) {
  await upsert(req.session.accountID, { ...req.body, id: req.params.id})

  return {
    location: `/`
  }
}

So we are now all setup to add new todo items and complete existing ones or are we?

Protecting Routes

Whoops! We forgot to protect our routes. We are going to create a new file called src/shared/auth.js to handle the authentication of our routes.

// src/shared/auth.js
const arc = require('@architect/functions')
const jwt_decode = require('jwt-decode')

module.exports = { auth }

async function auth(req) {
    const session = await arc.http.session.read(req)
    if (!session.token) {
        return {
            status: 401,
            json: { errors: [ 'authorization_token_missing' ] }
          }
    }

    try {
        const decoded = jwt_decode(session.token.id_token)
        if (decoded.aud !== process.env.AUTH0_CLIENT_ID) {
            return {
                status: 401,
                json: { errors: [ 'token_audience_invalid' ] }
            }
        }
    } catch (err) {
        return {
            status: 401,
            json: { errors: [ 'invalid_jwt' ] }
        }
    }
    return
}

The auth function will check to make sure there is an authorization token in the session and that it is valid. If there is no token or the token is invalid, the function will return a 401 response with the errors property set to an array containing the error message.

Then in both src/http/post-todos/index.js and src/http/post-todos-000id/index.js we will include the auth function and call it before handling the request.

const { auth } = require('@architect/shared/auth')

exports.handler = arc.http.async(auth, http)

Congrats you now have a functional todo app! Go try it out.

ToDo App

Psst! Want me to blow your mind? Try disabling JavaScript on your browser (Chrome, Firefox, Safari) and see what happens. The site still works because there is no client side JavaScript. Next time we will show how to progressively enhance the app to add more features while maintaining a base level of functionality.

Next Steps

Want to try it out for yourself:

  1. Sign up for a free account on Begin.

  2. Click the button below to deploy a this ToDo app to your account.

    deploy-to-begin

  3. Don’t forget to set the environment variables for Auth0 in the Begin console.

  4. As well as, setting the Allowed Callback URLs and Allowed Logout URLs in the Auth0 console.