Modern Backend with TypeScript, PostgreSQL, and Prisma: Passwordless Authentication and Authorization

In this third part of the series, we'll look at how to secure the REST API with passwordless authentication using Prisma for token storage and implement authorization.


Introduction

The goal of the series is to explore and demonstrate different patterns, problems, and architectures for a modern backend by solving a concrete problem: a grading system for online courses. This is a good example because it features diverse relations types and is complex enough to represent a real-world use case.

The recording of the live stream is available above and covers the same ground as this article.

What the series will cover

The series will focus on the role of the database in every aspect of backend development covering:

TopicPart
Data ModelingPart 1
CRUDPart 1
AggregationsPart 1
REST API layerPart 2
ValidationPart 2
TestingPart 2
Passwordless AuthenticationPart 3 (current)
AuthorizationPart 3 (current)
Integration with external APIsPart 3 (current)
DeploymentComing up

What you will learn today

In the first article, you designed a data model for the problem domain and wrote a seed script that uses Prisma Client to save data to the database.

In the second article of the series, you built a REST API on top of the data model and Prisma schema from the first article. You used Hapi to build the REST API, which allowed performing CRUD operations on resources via HTTP requests.

In this third article of the series, you will learn about the concepts behind authentication and authorization, how the two differ, and how to implement email-based passwordless authentication and authorization using JSON Web Tokens (JWT) with Hapi to secure the REST API.

Concretely, you will develop the following aspects:

  1. Passwordless Authentication: Add the ability to log in and sign up by sending an email with a unique token. Users complete the authentication process by sending the token received by email to the API and getting back a long-lived JWT token, giving access to API endpoints that require authentication.
  2. Authorization: Add authorization logic to restrict which resources users can access and manipulate.

By the end of this article, the REST API will be secured with authentication to access the REST endpoints. Additionally, you will add authorization rules to a subset of the endpoints using Hapi's pre route option, thereby granting access depending on the specific user's permissions. The API with the authorization rules for all the endpoints will be available in this GitHub repository.

Note: Throughout the guide, you'll find various checkpoints that enable you to validate whether you performed the steps correctly.

Prerequisites

Assumed knowledge

This series assumes basic knowledge of TypeScript, Node.js, and relational databases. If you're experienced with JavaScript but haven't had the chance to try TypeScript, you can still follow along. The series will use PostgreSQL. However, most of the concepts apply to other relational databases such as MySQL. Familiarity with REST concepts is useful. Beyond that, no prior knowledge of Prisma is required, as the series covers that.

Development environment

You should have the following installed:

  • Node.js (version 10, 12, or 14)
  • Docker (will be used to run a development PostgreSQL database)

If you're using Visual Studio Code, the Prisma extension is recommended for syntax highlighting, formatting, and other helpers.

Note: If you don't want to use Docker, you can set up a local PostgreSQL database or a hosted PostgreSQL database on Heroku.

External services

A SendGrid account is required so that you can send the passwordless authentication emails from the backend. SendGrid offers a free tier that allows sending up to 100 emails a day.

Once you sign up, go to API Keys in the SendGrid console, generate an API key, and keep it somewhere safe.

Clone the repository

The source code for the series can be found on GitHub.

To get started, clone the repository and install the dependencies:

git clone -b part-3 git@github.com:2color/real-world-grading-app.git
cd real-world-grading-app
npm install

Note: By checking out the part-3 branch you'll be able to follow the article from the same starting point.

Start PostgreSQL

To start PostgreSQL, run the following command from the real-world-grading-app folder:

docker-compose up -d

Note: Docker will use the docker-compose.yml file to start the PostgreSQL container.

Authentication and authorization concepts

Before diving into the implementation, we'll go through some concepts relating to authentication and authorization.

While the two terms are often used interchangeably, authentication and authorization serve different purposes. Generally speaking, they are both used to secure applications in complementary ways.

Put simply, authentication is the process of verifying who a user is, while authorization is the process of verifying what they have access to.

One example of authentication in the real world is a valid passport. The fact that you look like the person in an official document (that is hard to forge) authenticates that you are who you claim to be. For example, when you go to the airport, you present your passport, and you're allowed to go through security.

In the same example, authorization is the process by which you are allowed to board the flight: you present your boarding pass (that is typically scanned and verified against the database of flight passengers), and the ground attendant authorizes you to board the flight.

Authentication in web applications

Web applications typically use a username and password to authenticate users. If a valid username and password are passed, the application can verify that you're the user you claim to be because the password is supposed to be only known to you and the application.

Note: Web applications that use username/password authentication rarely store the password in clear text in the database. Instead, they use a technique called hashing to store a hash of the password. This allows the backend to verify the password without knowing it.

A hash function is a mathematical function that takes arbitrary input and always generates the same fixed-length string/number given the same input. The power of hash function lies in that you can go from a password to a hash but not from a hash to a password.

This allows verifying the password submitted by the user without storing the actual password. Storing password hashes protects users in the case of breached access to the database because it's not possible to log in with the hashed password.

In recent years, web security has become a growing concern, given the number of significant websites that have been breached. This trend has influenced how security is approached by introducing more secure authentication approaches such as multi-factor authentication.

Multi-factor authentication is an authentication method where the user is authenticated after successfully presenting two or more pieces of evidence (also known as factors). For example, when withdrawing money from an ATM, two authentication factors are required: possession of a bank card and a PIN code.

Since possession of a card is hard to verify for a web application, multi-factor authentication is often implemented by supplementing username/password with a one-time token generated by an authenticator app (an app installed on a smartphone or a special device which generates these passwords).

In this article, you will implement email-based passwordless authentication โ€“ a two-step approach that improves user experience and security. It works by sending a secret token to the user's email account when attempting to log in. Once the user opens the email and passes the token to the application, the application can authenticate the user and be certain that the user is the email account owner.

This approach relies on the user's email service, which can be assumed to have already authenticated the user. User experience is improved as the user doesn't need to set a password and remember it. Security is enhanced as the application is relieved from password management responsibilities, which can be an attack surface.

Outsourcing authentication to a user's email account means that the application will inherit the benefits and weaknesses of the user's email account security. But these days, most email services provide the option of second-factor authentication and other security measures.

Still, this approach avoids users choosing weak passwords, and likely reusing them on multiple websites. Removing passwords altogether means these users are more secure. There is no longer a password that can be guessed or brute-forced or cracked at all.

Authentication and signup/login flow

Email-based passwordless authentication is a two-step process that involves two token types.

The authentication flow will look as follows:

  1. The user calls the /login endpoint in the API with the email in the payload to begin the authentication process.
  2. If the email is new, the user is created in the User table.
  3. An email token is generated by the backend and saved in the Token table
  4. The email token is sent to the user's email
  5. The user sends the email token (received via email), and the email address to the /authenticate endpoint
  6. The backend validates the email token sent by the user. If valid and the token hasn't expired, a JWT token is generated and saved in the Token table.
  7. The JWT token is sent back to the user via the Authorization header.

There are two token types:

  1. Email token: A numerical eight-digit token that is valid for a short period, e.g. 10 minutes, and is sent to the user's email. The token's only purpose is to validate the user is associated with the email, which means it doesn't grant access to any of the grading-app related endpoints.
  2. Authentication token: A JWT token with tokenId in its payload. This token can be used to access protected endpoints by passing it in the Authorization header when making a request to the API. The token is long-lived in the sense that it's valid for 12 hours.

With this authentication strategy, a single endpoint handles both logging in and registration. It's is possible because the only difference between logging in and signing up is whether you're creating a row in the "User" table or not (if the user already exists).

JSON Web Tokens

JSON Web Tokens (JWT) are an open and standard method for representing claims securely between two parties. The standard defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed.

A JWT token contains three parts which are encoded with Base64: header, payload, and signature and looks as follows (the parts are separated with a .):

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJ0b2tlbklkIjo5fQ.
FkKMzLobPl_MaQHB7hRG3nZQZ-ME4lRaanGJVnLMa84

Note: Base64 is another way to represent data. It doesn't involve any encryption

If you use Base64 to decode the header and payload from above you will get the following:

  • Header: {"alg":"HS256","typ":"JWT"}
  • Payload: {"tokenId":9}

The token's signature part is created by passing the header, payload, and secret through a signing algorithm (in this case, HS256). The secret is only known to the backend and is used to verify the authenticity of the token.

In this article, JWT will be used for the long-lived authentication token. The token's payload will contain the tokenId, which will be stored in the database and reference to the user for which the token was created. This allows the backend to find the associated user.

Note: This approach is known as stateful JWT, where the token references a session that is stored in the database. While that means that authenticating a request requires a round-trip to the database, which increases the time needed to serve a request, this approach is more secure because tokens can be revoked from by the backend.

Adding a token model to the Prisma schema

You need to store the tokens in the database so they can be verified when requests are made. In this step, you will add a new Token model to the Prisma schema and update the User model to make some fields optional.

Open the Prisma schema located in prisma/schema.prisma and update as follows:

generator client {
  provider        = "prisma-client-js"
-  previewFeatures = ["aggregateApi"]
+  previewFeatures = ["connectOrCreate", "transactionApi"]
}

 model User {
  id        Int     @default(autoincrement()) @id
  email     String  @unique
-  firstName String
-  lastName  String
+  firstName String?
+  lastName  String?
   social    Json?

   // Relation fields
   courses     CourseEnrollment[]
   testResults TestResult[]       @relation(name: "results")
   testsGraded TestResult[]       @relation(name: "graded")
+  tokens      Token[]
 }
+
+model Token {
+  id         Int       @default(autoincrement()) @id
+  createdAt  DateTime  @default(now())
+  updatedAt  DateTime  @updatedAt
+  type       TokenType
+  emailToken String?   @unique // Only used for short lived email tokens
+  valid      Boolean   @default(true)
+  expiration DateTime
+
+  // Relation fields
+  user   User @relation(fields: [userId], references: [id])
+  userId Int
+}

+enum TokenType {
+  EMAIL // used as a short-lived token sent to the user's email
+  API
+}

Let's go over the changes introduced:

  • Enable the connectOrCreate and transactionApi preview features. These will be used in the next steps.
  • Remove the aggregateApi preview feature, which is stable as of Prisma 2.5.0.
  • In the User model the firstName and lastName are now optional. This allows users to log in/register with just an email.
  • A new Token model was added. Each user can have many tokens making the relation a 1-n. The Token model contains the relevant fields accomodating for expiration, the two token types (with the TokenType enum), and storage of the email token.

To migrate the database schema, save the migration, and then run it as follows:

# Save the migration
npx prisma migrate save --experimental --name "add-token"

# Run the migration
npx prisma migrate up --experimental

Add email sending functionality

Since the backend will send emails upon user login, you will create a plugin that will expose email sending functionality to the rest of the application. The Hapi plugin will follow a similar convention to the Prisma plugin.

The article will use SendGrid and the @sendgrid/mail npm package for easy integration with the SendGrid API.

Adding the dependency

npm install --save @sendgrid/mail

Creating the email plugin

Create a new file named email.ts in the src/plugins/ folder:

touch src/plugins/email.ts

And add the following to the file:

import Hapi from '@hapi/hapi'
import Joi from '@hapi/joi'
import Boom from '@hapi/boom'
import sendgrid from '@sendgrid/mail'

// Module augmentation to add shared application state
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/33809#issuecomment-472103564
declare module '@hapi/hapi' {
  interface ServerApplicationState {
    sendEmailToken(email: string, token: string): Promise<void>
  }
}

const emailPlugin = {
  name: 'app/email',
  register: async function(server: Hapi.Server) {
    if (!process.env.SENDGRID_API_KEY) {
      console.log(
        `The SENDGRID_API_KEY env var must be set, otherwise the API won't be able to send emails.`,
        `Using debug mode which logs the email tokens instead.`,
      )
      server.app.sendEmailToken = debugSendEmailToken
    } else {
      sendgrid.setApiKey(process.env.SENDGRID_API_KEY)
      server.app.sendEmailToken = sendEmailToken
    }
  },
}

export default emailPlugin

async function sendEmailToken(email: string, token: string) {
  const msg = {
    to: email,
    from: 'EMAIL_ADDRESS_CONFIGURED_IN_SEND_GRID@email.com',
    subject: 'Login token for the modern backend API',
    text: `The login token for the API is: ${token}`,
  }

  await sendgrid.send(msg)
}

async function debugSendEmailToken(email: string, token: string) {
  console.log(`email token for ${email}: ${token} `)
}

The plugin will expose the sendEmailToken function on the server.app object, which is accessible throughout your route handlers. It will use the SENDGRID_API_KEY environment variable, which you will set in production using the key from the SendGrid console. During development, you can leave it unset, and the token will be logged instead of being sent via email.

Lastly, register the plugin in server.ts:

import emailPlugin from './plugins/email'

await server.register([
  // ... existing plugins
  emailPlugin,
])

Adding authentication with Hapi

To implement authentication you will begin by defining the /login and /register routes, which will handle the creation of a user and token in the database, sending the email token, verifying the email, and generating a JWT authentication token. It's worth noting that the two endpoints will handle the authentication process, but they will not secure the API.

To secure the API, once the two routes are defined, you will define an authentication strategy that uses the jwt scheme provided by the hapi-auth-jwt2 library.

Note: Authentication in Hapi is based on the concept of schemes and strategies. Schemes are a way of handling authentication, whereas a strategy is a pre-configured instance of a schema. In this article, you will only need to define the strategy based on the jwt authentication scheme.

You will encapsulate all of this logic in an auth plugin.

Adding the dependencies

Begin by adding the following dependencies to your project:

npm install --save hapi-auth-jwt2@10.1.0 jsonwebtoken@8.5.1
npm install --save-dev @types/jsonwebtoken@8.5.0

Creating the auth plugin

Next, you will create an auth plugin to encapsulate the authentication logic.

Create a new file named auth.ts in the src/plugins/ folder:

touch src/plugins/auth.ts

And add the following to the file:

import Hapi from '@hapi/hapi'
import { TokenType, UserRole } from '@prisma/client'

const authPlugin: Hapi.Plugin<null> = {
  name: 'app/auth',
  dependencies: ['prisma', 'hapi-auth-jwt2', 'app/email'],
  register: async function(server: Hapi.Server) {
    // TODO: Add the authentication strategy
  },
}

export default plugin

Note: The auth plugin defines dependencies on the prisma, hapi-auth-jwt2, and app/email plugins. The prisma plugin was defined in part 2 of the series and will be used to access Prisma Client. The hapi-auth-jwt2 plugin defines the jwt authentication scheme, which you will use to define the authentication the strategy. Lastly, the app/email will ensure you can access the sendEmailToken function.

Defining the login endpoint

In the register function of authPlugin, define a new login route as follows:

server.route([
  // Endpoint to login or register and to send the short-lived token
  {
    method: 'POST',
    path: '/login',
    handler: loginHandler,
    options: {
      auth: false,
      validate: {
        payload: Joi.object({
          email: Joi.string()
            .email()
            .required(),
        }),
      },
    },
  },
])

Note: options.auth is set to false so that the endpoint will remain open once you set the default authentication strategy which will by default require authentication for all routes that don't disable it explicitly.

Outside the register function of the plugin, add the following:

const EMAIL_TOKEN_EXPIRATION_MINUTES = 10

interface LoginInput {
  email: string
}

async function loginHandler(request: Hapi.Request, h: Hapi.ResponseToolkit) {
  // ๐Ÿ‘‡ get prisma and the sendEmailToken from shared application state
  const { prisma, sendEmailToken } = request.server.app
  // ๐Ÿ‘‡ get the email from the request payload
  const { email } = request.payload as LoginInput
  // ๐Ÿ‘‡ generate an alphanumeric token
  const emailToken = generateEmailToken()
  // ๐Ÿ‘‡ create a date object for the email token expiration
  const tokenExpiration = add(new Date(), {
    minutes: EMAIL_TOKEN_EXPIRATION_MINUTES,
  })

  try {
    // ๐Ÿ‘‡ create a short lived token and update user or create if they don't exist
    const createdToken = await prisma.token.create({
      data: {
        emailToken,
        type: TokenType.EMAIL,
        expiration: tokenExpiration,
        user: {
          connectOrCreate: {
            create: {
              email,
            },
            where: {
              email,
            },
          },
        },
      },
    })

    // ๐Ÿ‘‡ send the email token
    await sendEmailToken(email, emailToken)
    return h.response().code(200)
  } catch (error) {
    return Boom.badImplementation(error.message)
  }
}

// Generate a random 8 digit number as the email token
function generateEmailToken(): string {
  return Math.floor(10000000 + Math.random() * 90000000).toString()
}

loginHandler does the following:

  • The email is taken from the request payload
  • A token is generated and then saved to the database
  • With connectOrCreate, if a user with the email address in the payload doesn't exist, it's created. Otherwise, a relation is created to the existing user.
  • The token is sent to the email address in the payload (or logged to the console if SENDGRID_API_KEY isn't set)

Finally, register the plugin in server.ts:

import authPlugin from './plugins/auth'

await server.register([
  // ... existing plugins
  authPlugin,
])

Checkpoint:

  1. Start the server with npm run dev
  2. Make a POST call to the /login endpoint with curl: curl --header "Content-Type: application/json" --request POST --data '{"email":"test@test.io"}' localhost:3000/login. You should see a token logged from the backend: email token for test@test.io: 27948216

Defining the authentication endpoint

At this point, the backend can create users, generate email tokens, and send them via email. However, the tokens generated are still not functional. You will now implement the second step of authentication by creating the /authenticate endpoint, verifying the email token against the database, and returning the user a long-lived JWT authentication token in the authorization header.

Begin by adding the following route declaration to the authPlugin:

server.route({
  method: 'POST',
  path: '/authenticate',
  handler: authenticateHandler,
  options: {
    auth: false,
    validate: {
      payload: Joi.object({
        email: Joi.string()
          .email()
          .required(),
        emailToken: Joi.string().required(),
      }),
    },
  },
})

The route requires both the email and the emailToken. Since only the legitimate user attempting to login will know both, guessing both the email and emailToken becomes more difficult, thereby reducing the risk of brute force attacks which guess the eight-digit number.

Next, add the following to auth.ts:

// Load the JWT secret from environment variables or default
const JWT_SECRET = process.env.JWT_SECRET || 'SUPER_SECRET_JWT_SECRET'

const JWT_ALGORITHM = 'HS256'

const AUTHENTICATION_TOKEN_EXPIRATION_HOURS = 12

interface AuthenticateInput {
  email: string
  emailToken: string
}

async function authenticateHandler(
  request: Hapi.Request,
  h: Hapi.ResponseToolkit,
) {
  // ๐Ÿ‘‡ get prisma from shared application state
  const { prisma } = request.server.app
  // ๐Ÿ‘‡ get the email and emailToken from the request payload
  const { email, emailToken } = request.payload as AuthenticateInput

  try {
    // Get short lived email token
    const fetchedEmailToken = await prisma.token.findOne({
      where: {
        emailToken: emailToken,
      },
      include: {
        user: true,
      },
    })

    if (!fetchedEmailToken?.valid) {
      // If the token doesn't exist or is not valid, return 401 unauthorized
      return Boom.unauthorized()
    }

    if (fetchedEmailToken.expiration < new Date()) {
      // If the token has expired, return 401 unauthorized
      return Boom.unauthorized('Token expired')
    }

    // If token matches the user email passed in the payload, generate long lived API token
    if (fetchedEmailToken?.user?.email === email) {
      const tokenExpiration = add(new Date(), {
        hours: AUTHENTICATION_TOKEN_EXPIRATION_HOURS,
      })
      // Persist token in DB so it's stateful
      const createdToken = await prisma.token.create({
        data: {
          type: TokenType.API,
          expiration: tokenExpiration,
          user: {
            connect: {
              email,
            },
          },
        },
      })

      // Invalidate the email token after it's been used
      await prisma.token.update({
        where: {
          id: fetchedEmailToken.id,
        },
        data: {
          valid: false,
        },
      })

      const authToken = generateAuthToken(createdToken.id)
      return h.response().code(200).header('Authorization', authToken)
    } else {
      return Boom.unauthorized()
    }
  } catch (error) {
    return Boom.badImplementation(error.message)
  }
}

// Generate a signed JWT token with the tokenId in the payload
function generateAuthToken(tokenId: number): string {
  const jwtPayload = { tokenId }

  return jwt.sign(jwtPayload, JWT_SECRET, {
    algorithm: JWT_ALGORITHM,
    noTimestamp: true,
  })
}

Note: The environment variable JWT_SECRET can be generated by running the following command: node -e "console.log(require('crypto').randomBytes(256).toString('base64'));". This should always be set in production environments.

The handler fetches the email token from the database, ensures it's valid, creates a new API token in the database, generates a JWT token (with a reference to the token in the database), invalidates the email token, and returns the token in the Authorization header.

Checkpoint:

  1. Start the server with npm run dev
  2. Make a POST call to the /login endpoint with curl: curl --header "Content-Type: application/json" --request POST --data '{"email":"test@test.io"}' localhost:3000/login you should see a token logged from the backend: email token for test@test.io: 13080740.
  3. Take that token and call the /authenticate endpoint with curl: curl -v --header "Content-Type: application/json" --request POST --data '{"email":"hello@prisma.io", "emailToken": "13080740"}' localhost:3000/authenticate.
  4. The response should have the 200 status and include an Authorization header which looks similar to this: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbklkIjoyOH0.gJYk3f1RJVKvPh75FdElCHwMoe_ZCMZftTE1Em5PpMg

Defining the authentication strategy

The authentication strategy will define how the Hapi will verify requests to endpoints that require authentication. In this step, you will define the logic for verifying requests with a JWT token, by fetching the information about the user from the database using the tokenId in the JWT token.

To define the authentication strategy, add the following to auth.ts:

// This strategy will be used across the application to secure routes
export const API_AUTH_STATEGY = 'API'

Inside the authPlugin.register function add the following:

// Define the authentication strategy which uses the `jwt` authentication scheme
server.auth.strategy(API_AUTH_STATEGY, 'jwt', {
  key: JWT_SECRET,
  verifyOptions: { algorithms: [JWT_ALGORITHM] },
  validate: validateAPIToken,
})

// Set the default authentication strategy for API routes, unless explicitly disabled
server.auth.default(API_AUTH_STATEGY)

Lastly, add the validateAPIToken function:

const apiTokenSchema = Joi.object({
  tokenId: Joi.number().integer().required(),
})

// Function will be called on every request using the auth strategy
const validateAPIToken = async (
  decoded: APITokenPayload,
  request: Hapi.Request,
  h: Hapi.ResponseToolkit,
) => {
  const { prisma } = request.server.app
  const { tokenId } = decoded
  // Validate the token payload adheres to the schema
  const { error } = apiTokenSchema.validate(decoded)

  if (error) {
    request.log(['error', 'auth'], `API token error: ${error.message}`)
    return { isValid: false }
  }

  try {
    // Fetch the token from DB to verify it's valid
    const fetchedToken = await prisma.token.findOne({
      where: {
        id: tokenId,
      },
      include: {
        user: true,
      },
    })

    // Check if token could be found in database and is valid
    if (!fetchedToken || !fetchedToken?.valid) {
      return { isValid: false, errorMessage: 'Invalid Token' }
    }

    // Check token expiration
    if (fetchedToken.expiration < new Date()) {
      return { isValid: false, errorMessage: 'Token expired' }
    }

    // Get all the courses that the user is the teacher of
    const teacherOf = await prisma.courseEnrollment.findMany({
      where: {
        userId: fetchedToken.userId,
        role: UserRole.TEACHER,
      },
      select: {
        courseId: true,
      },
    })

    // The token is valid. Make the `userId`, `isAdmin`, and `teacherOf` to `credentials` which is available in route handlers via `request.auth.credentials`
    return {
      isValid: true,
      credentials: {
        tokenId: decoded.tokenId,
        userId: fetchedToken.userId,
        isAdmin: fetchedToken.user.isAdmin,
        // convert teacherOf from an array of objects to an array of numbers
        teacherOf: teacherOf.map(({ courseId }) => courseId),
      },
    }
  } catch (error) {
    request.log(['error', 'auth', 'db'], error)
    return { isValid: false }
  }
}

The validateAPIToken function will get called before every route that uses the API_AUTH_STATEGY (which you've set as the default in the previous step).

The purpose of the validateAPIToken function is to determine whether to allow the request to proceed. This is done with the return object, which contains isValid and credentials:

  • isValid: determines whether the token was successfully verified.
  • credentials can be used to pass information about the user to the request object. The object passed to credentials is accessible within the route handler via request.auth.credentials.

In this case, we determine that if the token exists in the database, it is valid and hasn't expired. If so, we fetch the courses the user is the teacher of (which will be used to implement authorization) and pass that along with the tokenId, userId, and isAdmin to the credentials object.

Most endpoints require authentication (because of the default auth strategy), but there are still no authorization rules. That means that to access the GET /courses endpoint, you now need to have a valid JWT token in the Authorization header.

Checkpoint:

  1. Start the server with npm run dev
  2. Make a GET call to the /courses endpoint with curl: curl -v localhost:3000/courses. You should get a 401 status code with the following response: {"statusCode":401,"error":"Unauthorized","message":"Missing authentication"}.
  3. Make another call with the Authorization header with the token from the last checkpoint as follows: curl -H "Authorization:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbklkIjoyOH0.gJYk3f1RJVKvPh75FdElCHwMoe_ZCMZftTE1Em5PpMg" localhost:3000/courses and the request should succeed

Congratulations, you have successfully implemented email-based passwordless authentication and secured the endpoints. Next, you will define authorization rules.

Adding authorization

The authorization model of the backend will define what a user is allowed to do. In other words, which entities are they allowed to perform operations on.

The main properties that will grant users permissions are:

  • Is the user an admin (as denoted by the isAdmin fields in the user model)? If so, they will be allowed to perform every operation.
  • Is the user a teacher of a course? If so, the user will be allowed to perform CRUD operations on all course-specific resources such as tests, test results, and enrollment.

If a user is not an admin or a teacher of a course, they should still be able to create new courses, enroll as a student in existing courses, get their test results, and fetch and update their user profile.

Note: This approach mixes two authorization approaches, namely role-based and resource-based authorization. Deriving permissions from the course enrollment is a form of resource-based authorization. That means that actions are authorized based on a specific resource, i.e. enrollment in a course as a teacher allows the user to create related tests and submit test results. On the other hand, authorizing actions to admin users (with isAdmin set to true) is a form of role-based authorization where the user has the "admin" role.

Authorization rules for the endpoints

To implement the proposed authorization rules, we will first revisit the list of endpoints with the proposed authorization rules:

HTTP MethodRouteDescriptionAuthorization rule
POST/loginStart login/signup and send email tokenOpen
POST/authenticateAuthenticate user and create JWT tokenOpen (requires email token)
GET/profileGet the authenticated user profileAny authenticated user
POST/usersCreate a userOnly Admin
GET/users/{userId}Get a userOnly Admin or authenticated user
PUT/users/{userId}Update a userOnly Admin or authenticated user
DELETE/users/{userId}Delete a userOnly Admin or authenticated user
GET/usersGet usersOnly Admin
GET/users/{userId}/coursesGet a user's enrollement incoursesOnly Admin or authenticated user
POST/users/{userId}/coursesEnroll a user to a course (as student or teacher)Only Admin or authenticated user
DELETE/users/{userId}/courses/{courseId}Delete a user's enrollment to a courseOnly Admin or authenticated user
POST/coursesCreate a courseAny authenticated user
GET/coursesGet coursesAny authenticated user
GET/courses/{courseId}Get a courseAny authenticated user
PUT/courses/{courseId}Update a courseOnly admin or teacher of course
DELETE/courses/{courseId}Delete a courseOnly admin or teacher of course
POST/courses/{courseId}/testsCreate a test for a courseOnly admin or teacher of course
GET/courses/tests/{testId}Get a testAny authenticated user
PUT/courses/tests/{testId}Update a testOnly admin or teacher of course
DELETE/courses/tests/{testId}Delete a testOnly admin or teacher of course
GET/users/{userId}/test-resultsGet a user's test resultsOnly Admin or authenticated user
POST/courses/tests/{testId}/test-resultsCreate test result for a test associated with a userOnly admin or teacher of course
GET/courses/tests/{testId}/test-resultsGet multiple test results for a testOnly admin or teacher of course
PUT/courses/tests/test-results/{testResultId}Update a test result (associated with a user and a test)Only admin or grader of test
DELETE/courses/tests/test-results/{testResultId}Delete a test resultOnly admin or grader of test

Note: The paths containing a parameter enclosed in {}, e.g. {userId} represent a variable that is interpolated in the URL, e.g. in www.myapi.com/users/13 the userId is 13.

authorization with Hapi

Hapi routes have the notion of pre functions that allow breaking the handler logic into smaller and reusable functions. pre functions get called before the handler and allow taking over the response and returning an unauthorized error. This is useful in the context of authorization because many of the authorization rules proposed in the table above will be the same for multiple routes/endpoints. For example, checking whether the user is an admin will be the same for both the POST /users and the GET /users routes. That allows you to reuse a single isAdmin pre-function and assign to the two endpoints.

Adding authorization to the users endpoints

In this part, you will define pre functions to implement the different authorization rules. You will start with the three /users/{userId} endpoints (GET, POST, and DELETE) which should be authorized if the user making the request is an admin or if the user is requesting his own userId.

Note: Hapi also provides a way to implement role-based authentication declaratively with scopes. However, the proposed resource-based authorization approach โ€“where the user's permissions depend on the specific resource requestedโ€“ requires more granular control that cannot be done with scopes, so pre functions are used.

To add a pre-function to verify the authorization rule in the GET /users/{userId} route, declare the following function in src/plugins/user.ts:

// Pre-function to check if the authenticated user matches the requested user
export async function isRequestedUserOrAdmin(request: Hapi.Request, h: Hapi.ResponseToolkit) {
  // ๐Ÿ‘‡ userId and isAdmin are populated by the `validateAPIToken` function
  const { userId, isAdmin } = request.auth.credentials

  if (isAdmin) {
    // If the user is an admin allow
    return h.continue
  }

  const requestedUserId = parseInt(request.params.userId, 10)

  // ๐Ÿ‘‡ Check that the requested userId matches the authenticated userId
  if (requestedUserId === userId) {
    return h.continue
  }

  // The authenticated user is not authorized
  throw Boom.forbidden()
}

Then add the pre option to the route definition in src/plugins/user.ts as follows:

{
  method: 'GET',
  path: '/users/{userId}',
  handler: getUserHandler,
  options: {
+    pre: [isRequestedUserOrAdmin],
+    auth: {
+      mode: 'required',
+      strategy: API_AUTH_STATEGY,
+    },
    validate: {
      params: Joi.object({
        userId: Joi.number().integer(),
      }),
    },
  },
}

The pre-function will now be called before getUserHandler and only authorize access to admins or to users requesting their own userId.

Note: In the previous part, you've defined the default authentication strategy, so defining options.auth is not strictly required. But it's good practice to define the authentication requirements for every route explicitly.

Checkpoint: To verify the authorization logic has been correctly implemented, you will create a test user and test admin and call the /users/{userId} endpoint:

  1. Start the server with npm run dev
  2. Run the seed-users script to create a test user and test admin: npm run seed-users. You should get a result similar to this:
    Created test user   id: 1 | email: test@prisma.io
    Created test admin  id: 2 | email: test-admin@prisma.io
    
  3. Login as test@prisma.io by making a call to the POST /login endpoint as follows:
    curl --header "Content-Type: application/json" --request POST --data '{"email":"test@prisma.io"}' localhost:3000/login
    
  4. Take the logged token and call the /authenticate endpoint with curl:
    curl -v --header "Content-Type: application/json" --request POST --data '{"email":"test@prisma.io", "emailToken": "TOKEN_FROM_CONSOLE"}' localhost:3000/authenticate
    
  5. The response should have the 200 status and include an Authorization header which looks similar to this: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbklkIjoyOH0.gJYk3f1RJVKvPh75FdElCHwMoe_ZCMZftTE1Em5PpMg
  6. Make a GET call to /users/1 (where the number is the test user created in the first step of the checkpoint) with the Authorization header containing the token from the last checkpoint as follows: curl -H "Authorization:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbklkIjoyOH0.gJYk3f1RJVKvPh75FdElCHwMoe_ZCMZftTE1Em5PpMg" localhost:3000/users/1 and the request should succeed and you should see the user profile.
  7. Make another GET call to /users/2 with the same authorization header: curl -H "Authorization:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbklkIjoyOH0.gJYk3f1RJVKvPh75FdElCHwMoe_ZCMZftTE1Em5PpMg" localhost:3000/users/2. This one should fail with a 403 forbidden error.

If all steps succeeded, the isRequestedUserOrAdmin pre-function correctly authorizes users to access their own user profile. To test the admin functionality, repeat from the third step but log in as the test admin with the email test-admin@prisma.io. The admin should be able to get both user profiles.

Moving the authorization pre-function to a separate module

So far you've defined the isRequestedUserOrAdmin authorization pre-function and added it to the GET /users/{userId} route. To make use of this in different routes, move the function from src/plugins/users.ts to a separate module: src/auth-helpers.ts. This module will allow you to keep the authorization logic organized in a single place and reuse it for routes defined in different plugins, e.g. the GET /users/{userId}/courses route in user-enrollment.ts.

Once you've moved the isRequestedUserOrAdmin function into auth-helpers.ts, add it as a pre-function to the following routes, which have the same authorization logic:

ModuleRoute
src/plugins/users.tsDELETE /users/{userId}
src/plugins/users.tsPUT /users/{userId}
src/plugins/users-enrollment.tsGET /users/{userId}/courses
src/plugins/users-enrollment.tsPOST /users/{userId}/courses
src/plugins/users-enrollment.tsDELETE /users/{userId}/courses
src/plugins/test-results.tsGET /users/{userId}/test-results

Adding authorization to course specific endpoints

Teachers should be able to update courses and create tests for courses they are teachers of and admins. In this step, you will create another pre-function to verify that.

Define the following pre-function in auth-helpers.ts:

export async function isTeacherOfCourseOrAdmin(
  request: Hapi.Request,
  h: Hapi.ResponseToolkit,
) {
  // ๐Ÿ‘‡ isAdmin and teacherOf are populated by the `validateAPIToken` function
  const { isAdmin, teacherOf } = request.auth.credentials

  if (isAdmin) {
    // If the user is an admin allow
    return h.continue
  }

  const courseId = parseInt(request.params.courseId, 10)

  // Verify that the authenticated user is a teacher of the requested course
  if (teacherOf?.includes(courseId)) {
    return h.continue
  }
  // If the user is not a teacher of the course, deny access
  throw Boom.forbidden()
}

The pre-function uses the teacherOf array that is fetched in validateAPIToken to check if the user is a teacher of the requested course.

Add the isTeacherOfCourseOrAdmin as a pre-function to the following routes:

ModuleRoute
src/plugins/courses.tsPUT /courses/{courseId}
src/plugins/courses.tsDELETE /courses/{courseId}
src/plugins/tests.tsPOST /courses/{courseId}/tests

Update the routes from the table by adding the following options.pre:

options: {
  pre: [isTeacherOfCourseOrAdmin],
  // ... other route options
}

You have now implemented two different authorization rules and added then as a pre-function to ten different routes in the backend.

Updating the tests

After implementing authentication and authorization in the REST API, the tests will fail because the routes now require the user to be authenticated. In this step, you will adapt the tests to consider authentication.

For example, the GET /users/{userId} endpoint has the following test:

test('get user returns user', async () => {
  const response = await server.inject({
    method: 'GET',
    url: `/users/${userId}`,
  })
  expect(response.statusCode).toEqual(200)
  const user = JSON.parse(response.payload)

  expect(user.id).toBe(userId)
})

If you run this test now with npm run test -- -t="get user returns user" the test will fail. This is because the test when the request reaches the endpoint it does not meet its authentication requirements. With Hapi's server.inject โ€“that simulates an HTTP request to the serverโ€“, you can add an auth object with information about the authenticated user. The auth object sets the credentials object as they would in the validateAPIToken function in src/plugins/auth.ts, for example:

test('get user returns user', async () => {
  const response = await server.inject({
    method: 'GET',
    url: `/users/${testUser.id}`,
    auth: {
      strategy: API_AUTH_STATEGY,
      credentials: {
        userId: testUser.id,
        tokenId: // TODO: create the token and pass it here
        isAdmin: // TODO: set this based on the test user
        teacherOf: // TODO: set this based on the test user,
      },
    },
  })
  expect(response.statusCode).toEqual(200)
  const user = JSON.parse(response.payload)

  expect(user.id).toBe(testUserCredentials.userId)
})

The credentials object passed matches the AuthCredentials interface defined in src/plugins/auth.ts:

interface AuthCredentials {
  userId: number
  tokenId: number
  isAdmin: boolean
  teacherOf: number[]
}

Note: An interface in TypeScript is very similar to a type with some subtle differences. To learn more, checkout the TypeScript Handbook.

For the test to pass, you will create a user directly with Prisma in the test and construct the AuthCredentials object as follows:

test('get user returns user', async () => {
  const testUser = await server.app.prisma.user.create({
    data: {
      email: `test-${Date.now()}@test.com`,
      isAdmin: false,
      tokens: {
        create: {
          expiration: add(new Date(), { days: 7 }),
          type: TokenType.API,
        },
      },
    },
    include: {
      tokens: true,
    },
  })

  const testUserCredentials = {
    userId: testUser.id,
    tokenId: testUser.tokens[0].id,
    isAdmin: testUser.isAdmin,
    teacherOf: [], // empty array because no courses were created for the user
  }

  const response = await server.inject({
    method: 'GET',
    url: `/users/${testUserCredentials.userId}`,
    auth: {
      strategy: API_AUTH_STATEGY,
      credentials: testUserCredentials,
    },
  })
  expect(response.statusCode).toEqual(200)
  const user = JSON.parse(response.payload)

  expect(user.id).toBe(testUserCredentials.userId)
})

Checkpoint: Run npm run test -- -t="get user returns user" to verify that the test passes.

At this point, you've fixed one test, but what about the others? Since creating the credentials object will be required in most of the tests, you can abstract it into a separate test-helpers.ts module:

// Helper function to create a test user and return the credentials object the same way that the auth plugin does
export const createUserCredentials = async (
  prisma: PrismaClient,
  isAdmin: boolean,
): Promise<AuthCredentials> => {
  const testUser = await prisma.user.create({
    data: {
      email: `test-${Date.now()}@test.com`,
      isAdmin,
      tokens: {
        create: {
          expiration: add(new Date(), { days: 7 }),
          type: TokenType.API,
        },
      },
    },
    include: {
      tokens: true,
      courses: {
        where: {
          role: UserRole.TEACHER,
        },
        select: {
          courseId: true,
        },
      },
    },
  })

  return {
    userId: testUser.id,
    tokenId: testUser.tokens[0].id,
    isAdmin: testUser.isAdmin,
    teacherOf: testUser.courses?.map(({ courseId }) => courseId),
  }
}

As a next step, write a test that that verifies the authorization rule allowing admins to fetch different user accounts with the GET /users/{userId} endpoint.

Summary and next steps

Congratulations on making it this far. The article covered many concepts, starting with authentication and authorization concepts to implementing email-based passwordless authentication with Prisma, Hapi, and JWT. Lastly, you implemented authorization rules with Hapi's pre-functions. You also created an email plugin to provide the backend with the ability to send emails with SendGrid's API.

The auth plugin encapsulated the two routes for the authentication flow and used the jwt authentication scheme to define the authentication strategy. In the authentication strategy's validate function, you checked tokens against the database and populated the credentials object with information relevant for the authorization rules.

You also carried out a database migration and introduced a new Token table with an n-1 relation to the User table with Prisma Migrate.

TypeScript helped auto-completing and verifying the correct use of types (ensuring they are in sync with the database schema).

You used Prisma Client extensively to fetch and persist data in the database.

The article covered authorization for a subset of all the endpoints. As next steps, you could do the following:

  • Add authorization to the rest of the routes following the same principles.
  • Add the credential object to all the tests.
  • Generate and set the JWT_SECRET environment variable.
  • Set the SENDGRID_API_KEY environment variable and test the email functionality.

You can find the full source code on GitHub with authorization rules for all the endpoints implemented, and the tests adapted.

While Prisma aims to make working with relational databases easy, it's useful to understand the underlying database and authentication principles.

If you have questions, feel free to reach out on Twitter.

Join the discussion

Follow @prisma on Twitter

Donโ€™t miss the next post!

Sign up for the Prisma newsletter

Newsletter

Stay up to date with the latest features and changes to Prisma

Find Us

Made with โค๏ธ in Berlin