Implementing JWT Authentication with TRPC and Express

Implementing JWT Authentication with TRPC and Express

With the surge in APIs and their consumption among web companies globally, API security has become increasingly important.

JWT authentication is a standard way of protecting APIs. It excels at validating the information sent over the wire between APIs and the clients who use the APIs. Even passing claims between the communicating parties is secure.

In this article, you’ll learn how to use JWT in TRPC projects to secure your APIs and authenticate users.

Introduction to tRPC

tRPC allows you to build and consume fully typesafe APIs without code generation. It enables you to build statically typed API endpoints and share those endpoints between our client and server (or server-to-server).

Prerequisites

This tutorial assumes the reader has the following:

Authentication, Session Management, and JWT

Authentication with tokens is stateless. Therefore, the server is not required to keep valid authentication records. We don't experience CORS problems because the token isn't kept in a cookie, which is an added benefit. Every request must include the token which the user must supply. The token contains user data that has been encoded.

jwt.png Source: losikov.medium.com/part-6-authentication-wi..

The response is served after this token has been validated. The token is validated by either verifying the information's signature or the signature itself. This method does not require the server to retain any token data. The token contains all the necessary information. As a result, its implementation is perfect for scalability.

Step 1 - Create a directory and initialize yarn

Create a directory and initialize yarn by typing the following commands

mkdir jwt-auth
cd jwt-auth
yarn init -y

Step 2 - Install dependencies

The next step is to install the necessary dependencies and dev dependencies. We’ll be using prisma as our ORM and zod for schema validations

yarn add express @prisma/client @trpc/server bcryptjs dotenv prisma superjson zod jsonwebtoken cors lodash


yarn add @types/cors @types/express @types/jsonwebtoken @types/lodash nodemon ts-node typescript -D

Step 3 - Initialize Prisma and connect to the database

The next step is to connect to our database, luckily, Prisma has a lazy way of connecting to our database, requiring the minimum of effort from us. In this post, we’ll be connecting to a postgresql database. To connect to our database, we need to generate our Prisma client by running this command in the terminal of our project directory.

jwt-auth $ npx prisma generate

A prisma folder is automatically generated and added to our project directory, inside this prisma folder is our schema which we are going to be designing our model structures.

generator client {
    provider="prisma-client-js"
}

datasource db {
    provider = "postgresql"
     url = env("DATABASE_URL")
}

Create a .env file in the root folder of your project and add the postgresql database URI as seen below

.env -

DATABASE_URL="postgresql://username:password.@localhost:5432/jwt-auth?schema=public"

The next step is to create our node.js server and add the express adapter from the @trpc/server package

Step 4 - Create a node server and add the trpc-express middleware

The next step is to create a node server to listen on a port you configure as seen below. tRPC includes an adapter for Express.js out of the box. This adapter lets us convert our tRPC router into an Express.js middleware.

index.ts -

 import express from 'express';
 import * as trpcExpress from '@trpc/server/adapters/express';
 import * as dotenv from 'dotenv';
 import cors from 'cors';
 import { appRouter } from './routes/app.router';
 import { createContext } from './routes/createContext';
 import logger from './utils/logger.utils';

 dotenv.config();

 const app = express();
 app.use(cors());
 const port = process.env.PORT;

// app.use(AuthMiddleware.deserializeUser);
/* Creating a middleware that will handle all requests to the /api/jwt-auth endpoint. */
 app.use(
  '/api/jwt-auth',
  trpcExpress.createExpressMiddleware({
    router: appRouter,
    createContext,
   })
  );

/* This is a route handler that will handle all requests to the root path. */
 app.get('/', (req, res) => {
     res.send('Hello from jwt-auth');
 });

/* Listening to the port 1337. */
 app.listen(port, () => {
  logger.info(`app listening on port http://localhost:${port}`);
 });

To get our app running, we need to add a starting script to our package.json file as shown below:

"scripts": {
    "start": "ts-node src/index.ts",
    "dev": "nodemon src/index.ts"
}

To run the server in development mode, we need to run yarn dev. Our server should be up and running. It is worth noting that because Prisma handles database connection for use under the hood, our app won’t fully establish a connection until we fire our first API request.

Step 5 - Create the user model

Next, let’s create our user model and route. We’ll define our model when signing up for the first time and validate it against the saved credentials when logged in.

Add the following snippet to schema.prisma inside the prisma folder.

generator client {
    provider="prisma-client-js"
}

datasource db {
    provider = "postgresql"
     url = env("DATABASE_URL")
}

model User {
    id String @unique @default(uuid())
    email String @unique
    password String 
}

After adding our user model to the Prisma schema, we need to run the migrate command to sync with our Postgres database.

yarn prisma migrate dev

Step 6 - Create the context object and add it to the base router

To create our router, we’ll create a file called createRouter.ts. Import the router module from @trpc/server package and set it up as seen below. Here we are passing the context object into the router and using the superjson package as a data transformer for our API.

router/createRouter.ts -

import {router} from "@trpc/server"
import superjson from "superjson"
import {Context} from "./createContext"


export function createRouter(){
    return router<Context>().transformer(superjson)
}

Our context file is as described below. We are getting the request and response type from express and passing it into our context. We also saved ourselves a lot of stress by passing the Prisma client along with the context. This means we won’t have to import the Prisma client every time we need it.

router/createContext.ts -

import {Request, Response} from "express";
import {PrismaClient} from "@prisma/client"
import {prisma} from "../utils/prisma.utils";

export function createContext({req, res}: {req: Request; res: Response}){
    return {res, res, prisma}
}

export type Context = ReturnType<typeof createContext>;

utils/prisma.utils.ts -

import {PrismaClient} from "@prisma/client"

declare global {
    var prisma: PrismaClient | undefined
}

if(process.env.NODE_ENV !== "production") {
    global.prisma = prisma
}

export const prisma = new PrismaClient()

Step 7 - Implement Signup and Login functionality

The signup and login functions, which serve as the controllers for the routes, will be implemented in our application. Before putting the credentials in the database, we will use JWT to sign them and bcrypt to encrypt the password.

For the signup controller, we will:

  • Get user input
  • Validate user input
  • Validate if the user already exists
  • Encrypt the user password
  • Create a user in our database
  • And finally create a signed token

controllers/auth.controller.ts -

import * as trpc from '@trpc/server'
import bcrypt from 'bcryptjs'
import {get} from 'lodash'
import fs from 'fs'
import {CreateSignupInput} from '../schemas/auth.schema'
import {Context} from '../routes/createContext'
import { generateOtp, verifyOtpExpiration, createHashedToken, createRandomToken } from '../helpers';
import keys from '../config/keys';
import { signJwt, verifyJwt } from '../utils/jwt.utils';
import sendEmail from '../utils/oauthmailer.utils';


const base = process.env.PWD;
const accessTokenPublicKey = fs.readFileSync(base + '/src/cert/access_public.pem', 'utf8');
const refreshTokenPublicKey = fs.readFileSync(base + '/src/cert/refresh_public.pem', 'utf8');
const accessTokenPrivateKey = fs.readFileSync(base + '/src/cert/access_private.pem', 'utf8');
const refreshTokenPrivateKey = fs.readFileSync(base + '/src/cert/refresh_private.pem', 'utf8');

export class AuthController {
    static async signupUser(ctx:Context, input:CreateSignupInput){
        try {
            const { email, password } = input;
      const hashedPassword = await bcrypt.hash(password, 10);

      // Check if user already exists
      const user_exist = await ctx.prisma.user.findUnique({
        where: {
          email,
        },
      });

      if (user_exist) {
        throw new trpc.TRPCError({
          code: 'CONFLICT',
          message: 'User already exists',
        });
      }

     const user = await ctx.prisma.user.create({
        data: {
            email,
            password: hashedPassword,
       }
     })

const accessToken = signJwt({ id: user.id, email: user.email }, accessTokenPrivateKey, {
        expiresIn: process.env.ACCESS_TOKEN_TTL,
      });

      const refreshToken = signJwt({ id: user.id, email: user.email },                  refreshTokenPrivateKey, {
        expiresIn: process.env.REFRESH_TOKEN_TTL,
      });


       return {
          message: 'Signup Successful, please proceed to login',
          status: 'success',
          user,
          accesstoken,
          refreshToken
        };

     }catch(error:any){
    throw new trpc.TRPCError(error)
    }
  }
}

For the login controller, we will:

  • Get user input (email and password)
  • Validate user input
  • Validate if the user exists
  • Verify user password against the password we saved earlier in our database
  • And finally, create an access and refresh token for the user
static async loginUser(ctx: Context, input: LoginInput) {
    try {
      const { email, password } = input;

      const user = await ctx.prisma.user.findUnique({
        where: { email: email },
      });

      const passwordMatch = await bcrypt.compare(password, (user as Record<string, any>).password);

      if (!user || !passwordMatch) {
        throw new trpc.TRPCError({
          code: 'UNAUTHORIZED',
          message: 'Invalid email or password',
        });
      }

      const lastLogin = new Date(Date.now());

      const updatedUser = await ctx.prisma.user.update({
        where: { id: user.id },
        data: { lastLogin },e
      });

      const accessToken = signJwt({ id: updatedUser.id, email: updatedUser.email }, accessTokenPrivateKey, {
        expiresIn: process.env.ACCESS_TOKEN_TTL,
      });

      const refreshToken = signJwt({ id: updatedUser.id, email: updatedUser.email }, refreshTokenPrivateKey, {
        expiresIn: process.env.REFRESH_TOKEN_TTL,
      });

      return {
        accessToken,
        refreshToken,
        message: 'Login successful',
        status: 'success',
      };
    } catch (error: any) {
      throw new trpc.TRPCError(error);
    }
  }



import { createRouter } from './createRouter';
import {
  createAuthSchema,
  loginSchema,
} from '../schemas/auth.schema';
import AuthController from '../services/auth.service';
import AuthMiddleware from '../middlewares/auth.middleware';

export const authRouter = createRouter()
  .middleware(async ({ ctx, next }) => {
    await AuthMiddleware.deserializeUser(ctx);
    return next();
  })
  .mutation('signup', {
    input: createAuthSchema,
    async resolve({ ctx, input }) {
      return await AuthController.signupUser(ctx, input);
    },
  })
  .mutation('login', {
    input: loginSchema,
    async resolve({ ctx, input }) {
      return await AuthController.loginUser(ctx, input);
    },
  })

Step 8 - Create middleware for authentication

We can successfully create and log in a user. However, we need a way to protect our private routes and restrict them to only users that are logged in. Let’s see how we can do that.

The middleware checks if the user’s access token is present in the request header, and if it’s present, it checks the token's validity using our signature. The user object is attached to the context object and authorized if the token is valid.

middleware/auth.middleware.ts -

static async deserializeUser(ctx: Context) {
    try {
      const accessToken = get(ctx.req, 'headers.authorization', '').replace(/^Bearer\s/, '');
      // const refreshToken = get(ctx.req, 'headers.x-refresh');
      const { decoded, expired } = verifyJwt(accessToken, accessTokenPublicKey);

      if (!accessToken || decoded === null) {
        throw new trpc.TRPCError({
          code: 'UNAUTHORIZED',
          message: 'You are not logged in! Please log in and try again',
        });
      }

      if (decoded && expired === true) {
        throw new trpc.TRPCError({
          code: 'UNAUTHORIZED',
          message: 'Expired token',
        });
      }

      const user = await ctx.prismaLive.user.findUnique({ where: { id: decoded.id } });

      if (!user) {
        throw new trpc.TRPCError({
          code: 'UNAUTHORIZED',
          message: 'The user belonging to this token no longer exist',
        });
      }

      /**
       * If the user has changed their password, then return true if the JWT timestamp is less than the
       * password changed timestamp
       * @param {number} JWTTimestamp - The timestamp of when the token was issued.
       * @returns A boolean value.
       */
      const changedPasswordAfter = (JWTTimestamp: number) => {
        if (user.passwordChangedAt) {
          const changedTimestamp = user.passwordChangedAt.getTime() / 100;

          return JWTTimestamp < changedTimestamp;
        }

        return false;
      };

      if (changedPasswordAfter(decoded.iat)) {
        throw new trpc.TRPCError({
          code: 'UNAUTHORIZED',
          message: 'You recently changed your password! Please log in again',
        });
      }

      ctx.res.locals.user = user;
    } catch (error: any) {
      throw new trpc.TRPCError(error);
    }
  }

Conclusion

In this tutorial, we’ve set up a tRPC project with express and Prisma. We’ve been able to create users and also log users into our API. We were also able to authenticate users through our middleware by checking if they have the appropriate authorization header set and if the request header has a valid JWT token signed with our specific private key.

What’s next?

The fun doesn’t end here. It continues. In the next post, we’ll learn how to connect these API endpoints with a React frontend application.

Please share this article with anybody you think would benefit from it and leave a comment below if you have any questions or concerns.