When we build a full-stack TypeScript application, it becomes challenging to share types directly between the client and server, without depending on code generation or schemas defined on the server.

This is where GraphQL was developed to offer a radically new way to build type-safety APIs with more flexibility and control.

But the flexibility that GraphQL brought came at a price in the form of extra setup and complexity. For this reason, crop frameworks, libraries, and services were introduced to help smoothen the workflows and provide better patterns to reduce the complexities.

With all the advantages GraphQL has over RESTful API, it doesn’t take full advantage of TypeScript to build type-safety APIs.

In this article, you’ll learn how to add access and refresh token functionality to your tRPC API using Node.js, Express, MongoDB, Redis, and Docker.

tRPC API with React.js, Express, and Node.js Series:

  1. Build tRPC API with React.js, Node.js & MongoDB: Project Setup
  2. Build tRPC API with React.js & Node.js: Access and Refresh Tokens
  3. Full-Stack App tRPC, React.js, & Node.js: JWT Authentication

Related Articles:

Build tRPC API with React.js, MongoDB, Redis, Docker & Node.js Access and Refresh Tokens

Introduction

Due to the flaws of GraphQL, tRPC was introduced to provide better ways to statically type our APIs and share those types between the frontend and backend.

tRPC can be considered as a wrapper around RESTful APIs but it provides a simpler pattern for building type-safety APIs with TypeScript and couples the client and server more tightly together.

Prerequisites

Before we begin, you should:

Project Setup

We are going to use Express and tRPC along with the Express adapter to create the tRPC endpoints.

Also, we will use MongoDB as our database and Redis to store the authenticated user’s session. Adding Redis to the authentication flow will add an extra layer of security to our application.

Follow the Project Setup article to set up the tRPC project with Express before continuing with this article.

Create the User Model with Typegoose

Before we start defining the models, open your terminal and change the directory into the server folder.

Run this command to install typegoose and bcryptjs :


yarn add @typegoose/typegoose bcryptjs && yarn add -D @types/bcryptjs

  • typegoose – for defining Mongoose schemas with TypeScript classes and decorators.
  • bcryptjs – for hashing the user’s password

Now create a packages/server/src/models/user.model.ts file and add the following code snippets:

packages/server/src/models/user.model.ts


import {
  getModelForClass,
  index,
  modelOptions,
  pre,
  prop,
} from '@typegoose/typegoose';
import bcrypt from 'bcryptjs';

@index({ email: 1 })
@pre<User>('save', async function () {
  // Hash password if the password is new or was updated
  if (!this.isModified('password')) return;

  // Hash password with costFactor of 12
  this.password = await bcrypt.hash(this.password, 12);
})
@modelOptions({
  schemaOptions: {
    // Add createdAt and updatedAt fields
    timestamps: true,
  },
})

// Export the User class to be used as TypeScript type
export class User {
  @prop()
  name: string;

  @prop({ unique: true, required: true })
  email: string;

  @prop({ required: true, minlength: 8, maxLength: 32, select: false })
  password: string;

  @prop({ default: 'user' })
  role: string;

  @prop({ required: true })
  photo: string;

  // Instance method to check if passwords match
  async comparePasswords(hashedPassword: string, candidatePassword: string) {
    return await bcrypt.compare(candidatePassword, hashedPassword);
  }
}

// Create the user model from the User class
const userModel = getModelForClass<typeof User>(User);
export default userModel;

Key things to note in the above code:

  • First, we created a TypeScript class and defined the attributes of a user using the Typegoose @prop() decorators.
  • We created a comparePasswords() instance method that will be available on the user model. Later, we will evoke it to validate the user’s password against the hashed one in the database.
  • Next, we used the @modelOptions() decorator with some configurations to tell Mongoose to add the “createdAt” and “updatedAt” timestamps.
  • Since we’ll be querying the database with the user’s email, we added an index to the email field to speed up the query.
  • Next, we used the Typegoose pre-save hook to hash the user’s password before adding the document to the database.
  • Lastly, we extracted the mongoose model from the TypeScript class using the getModelForClass() function provided by Typegoose.

Create the Schemas with Zod

tRPC works out-of-the-box with popular validation libraries like Yup, Superstruct, Zod, and myzod. It also allows you to use your own custom validators to validate the request body before the request gets to the resolvers.

Despite having all those validators, we will use Zod since it’s a TypeScript-first schema declaration and validation library.

Install the Zod library:


yarn add zod

Now create a packages/server/src/schemas/user.schema.ts file and add the following code:


import { object, string, TypeOf } from 'zod';

export const createUserSchema = object({
  name: string({ required_error: 'Name is required' }),
  email: string({ required_error: 'Email is required' }).email('Invalid email'),
  photo: string({ required_error: 'Photo is required' }),
  password: string({ required_error: 'Password is required' })
    .min(8, 'Password must be more than 8 characters')
    .max(32, 'Password must be less than 32 characters'),
  passwordConfirm: string({ required_error: 'Please confirm your password' }),
}).refine((data) => data.password === data.passwordConfirm, {
  path: ['passwordConfirm'],
  message: 'Passwords do not match',
});

export const loginUserSchema = object({
  email: string({ required_error: 'Email is required' }).email(
    'Invalid email or password'
  ),
  password: string({ required_error: 'Password is required' }).min(
    8,
    'Invalid email or password'
  ),
});

export type CreateUserInput = TypeOf<typeof createUserSchema>;
export type LoginUserInput = TypeOf<typeof loginUserSchema>;

If you take a careful look at the above code, we retrieved the TypeScript types from the schemas using the TypeOf<> type provided by Zod. Later we will need those types when defining the controllers.

Creating Utility Functions to Sign and Verify JWTs

Run this command to install jsonwebtoken package:


yarn add jsonwebtoken && yarn add -D @types/jsonwebtoken

If you want to learn more about JWT Authentication with React, check out React + Redux Toolkit: JWT Authentication and Authorization

If you want to learn more about Refresh Tokens with React, check out React.js + Redux Toolkit: Refresh Tokens Authentication

How to Generate the JWT Private and Public Keys

To reduce the complexity of this project, I already added the private and public keys to the packages/server/.env file but you can follow the steps below to generate them yourself.

Step 1: Go to this website, change the key size to 512 bits and click the blue “Generate New Keys” button to generate the private and public keys.

Step 2: Copy the private key and visit this website to convert it to Base64.

We are encoding the keys to avoid getting unnecessary warnings in the terminal when building the Docker images.

Maybe you might not get the warnings in Node.js but when I was working in Python I was getting those warnings in the terminal.

Step 3: Copy the encoded key and add it to the packages/server/.env file as ACCESS_TOKEN_PRIVATE_KEY .

Step 4: Copy the corresponding public key and encode it before adding it to the packages/server/.env file as ACCESS_TOKEN_PUBLIC_KEY .

Step 5: Repeat the process for the refresh token private and public keys.

After adding the private and public keys to the environment variables file, update the packages/server/src/config/default.ts file to enable the dotenv package to load them into the Node.js environment.

packages/server/src/config/default.ts


import path from 'path';
require('dotenv').config({ path: path.join(__dirname, '../../.env') });

const customConfig: {
  port: number;
  accessTokenExpiresIn: number;
  refreshTokenExpiresIn: number;
  origin: string;
  dbUri: string;
  accessTokenPrivateKey: string;
  accessTokenPublicKey: string;
  refreshTokenPrivateKey: string;
  refreshTokenPublicKey: string;
} = {
  port: 8000,
  accessTokenExpiresIn: 15,
  refreshTokenExpiresIn: 60,
  origin: 'http://localhost:3000',

  dbUri: process.env.MONGODB_URI as string,
  accessTokenPrivateKey: process.env.ACCESS_TOKEN_PRIVATE_KEY as string,
  accessTokenPublicKey: process.env.ACCESS_TOKEN_PUBLIC_KEY as string,
  refreshTokenPrivateKey: process.env.REFRESH_TOKEN_PRIVATE_KEY as string,
  refreshTokenPublicKey: process.env.REFRESH_TOKEN_PUBLIC_KEY as string,
};

export default customConfig;

With that out of the way, let’s create two functions to generate and verify the tokens with the jsonwebtoken package.

Create a packages/server/src/utils/jwt.ts file and add the following:

packages/server/src/utils/jwt.ts


import jwt, { SignOptions } from 'jsonwebtoken';
import customConfig from '../config/default';

export const signJwt = (
  payload: Object,
  key: 'accessTokenPrivateKey' | 'refreshTokenPrivateKey',
  options: SignOptions = {}
) => {
  const privateKey = Buffer.from(customConfig[key], 'base64').toString('ascii');
  return jwt.sign(payload, privateKey, {
    ...(options && options),
    algorithm: 'RS256',
  });
};

export const verifyJwt = <T>(
  token: string,
  key: 'accessTokenPublicKey' | 'refreshTokenPublicKey'
): T | null => {
  try {
    const publicKey = Buffer.from(customConfig[key], 'base64').toString(
      'ascii'
    );
    return jwt.verify(token, publicKey) as T;
  } catch (error) {
    console.log(error);
    return null;
  }
};

In the above, you’ll notice that we decoded the encoded private and public keys back to ASCII strings before passing them to the jsonwebtoken methods.

Creating the Database Services

When designing an API architecture, it’s always a good practice to separate the business logic from the actual implementation.

In other words, you should define services to query and mutate the database. That means the controllers should not have direct access to the database.

The recommendation is to push most of the business logic to the models or services, leaving us with thin controllers and large models or services.

Now install the lodash package to help us filter the data returned by the database to avoid sending sensitive information to the user.


yarn add lodash && yarn add -D @types/lodash

Next, create a packages/server/src/services/user.service.ts file and add the following code:

packages/server/src/services/user.service.ts


import { omit } from 'lodash';
import { FilterQuery, QueryOptions } from 'mongoose';
import userModel, { User } from '../models/user.model';
import { signJwt } from '../utils/jwt';
import redisClient from '../utils/connectRedis';
import { DocumentType } from '@typegoose/typegoose';
import customConfig from '../config/default';

// Exclude this fields from the response
export const excludedFields = ['password'];

// CreateUser service
export const createUser = async (input: Partial<User>) => {
  const user = await userModel.create(input);
  return omit(user.toJSON(), excludedFields);
};

// Find User by Id
export const findUserById = async (id: string) => {
  return await userModel.findById(id).lean();
};

// Find All users
export const findAllUsers = async () => {
  return await userModel.find();
};

// Find one user by any fields
export const findUser = async (
  query: FilterQuery<User>,
  options: QueryOptions = {}
) => {
  return await userModel.findOne(query, {}, options).select('+password');
};

// Sign Token
export const signToken = async (user: DocumentType<User>) => {
  const userId = user._id.toString();
  // Sign the access token
  const access_token = signJwt({ sub: userId }, 'accessTokenPrivateKey', {
    expiresIn: `${customConfig.accessTokenExpiresIn}m`,
  });

  // Sign the refresh token
  const refresh_token = signJwt({ sub: userId }, 'refreshTokenPrivateKey', {
    expiresIn: `${customConfig.refreshTokenExpiresIn}m`,
  });

  // Create a Session
  redisClient.set(userId, JSON.stringify(user), {
    EX: 60 * 60,
  });

  // Return access token
  return { access_token, refresh_token };
};

Creating the Authentication Controllers

We are now ready to create the authentication controllers to:

  1. Register a new user
  2. Refresh the access token
  3. Sign in the registered user
  4. Logout the authenticated user

Before that, let’s create the cookie options. Create a packages/server/src/controllers/auth.controller.ts file and add the following:

packages/server/src/controllers/auth.controller.ts


import { TRPCError } from '@trpc/server';
import { CookieOptions } from 'express';
import { Context } from '../app';
import customConfig from '../config/default';
import { CreateUserInput, LoginUserInput } from '../schema/user.schema';
import {
  createUser,
  findUser,
  findUserById,
  signToken,
} from '../services/user.service';
import redisClient from '../utils/connectRedis';
import { signJwt, verifyJwt } from '../utils/jwt';

const cookieOptions: CookieOptions = {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax',
};

// Cookie options
const accessTokenCookieOptions = {
  ...cookieOptions,
  expires: new Date(Date.now() + customConfig.accessTokenExpiresIn * 60 * 1000),
};

const refreshTokenCookieOptions = {
  ...cookieOptions,
  expires: new Date(
    Date.now() + customConfig.refreshTokenExpiresIn * 60 * 1000
  ),
};

Register User Controller

packages/server/src/controllers/auth.controller.ts


// Imports [...]

// Cookie options [...]

// Register a new user
export const registerHandler = async ({
  input,
}: {
  input: CreateUserInput;
}) => {
  try {
    const user = await createUser({
      email: input.email,
      name: input.name,
      password: input.password,
      photo: input.photo,
    });

    return {
      status: 'success',
      data: {
        user,
      },
    };
  } catch (err: any) {
    if (err.code === 11000) {
      throw new TRPCError({
        code: 'CONFLICT',
        message: 'Email already exists',
      });
    }
    throw err;
  }
};

In the above, we called the createUser() service we defined to add the new user to the database.

Also, since we added a unique constraint to the email field, MongoDB will return an error with a 11000 code indicating that a user with that email already exists in the database. So it makes a lot of sense to catch that error to prevent the application from crashing.

Sign in User Controller

Since we are able to register a user, let’s create the tRPC controller to sign in the registered user.

In a future article, we will add email verification logic to the authentication flow.

packages/server/src/controllers/auth.controller.ts


// Imports [...]

// Cookie options [...]

// Register a new user

// Login a user
export const loginHandler = async ({
  input,
  ctx,
}: {
  input: LoginUserInput;
  ctx: Context;
}) => {
  try {
    // Get the user from the collection
    const user = await findUser({ email: input.email });

    // Check if user exist and password is correct
    if (
      !user ||
      !(await user.comparePasswords(user.password, input.password))
    ) {
      throw new TRPCError({
        code: 'BAD_REQUEST',
        message: 'Invalid email or password',
      });
    }

    // Create the Access and refresh Tokens
    const { access_token, refresh_token } = await signToken(user);

    // Send Access Token in Cookie
    ctx.res.cookie('access_token', access_token, accessTokenCookieOptions);
    ctx.res.cookie('refresh_token', refresh_token, refreshTokenCookieOptions);
    ctx.res.cookie('logged_in', true, {
      ...accessTokenCookieOptions,
      httpOnly: false,
    });

    // Send Access Token
    return {
      status: 'success',
      access_token,
    };
  } catch (err: any) {
    throw err;
  }
};

Below is what we did above:

  • First, we called the findUser() service to check if a user with that email exists in the database.
  • Next, we called the comparePasswords() instance method we added to the user class to validate the provided password against the hashed one in the database.
  • Lastly, we generated the access and refresh tokens and returned them to the user’s browser or client as HTTPOnly cookies.

Refresh Access Token Controller

Now let’s create a tRPC handler that will be called to generate a new access token.

packages/server/src/controllers/auth.controller.ts


// Imports [...]

// Cookie options [...]

// Register a new user

// Login a user

// Refresh tokens handler
export const refreshAccessTokenHandler = async ({ ctx }: { ctx: Context }) => {
  try {
    // Get the refresh token from cookie
    const refresh_token = ctx.req.cookies.refresh_token as string;

    const message = 'Could not refresh access token';
    if (!refresh_token) {
      throw new TRPCError({ code: 'FORBIDDEN', message });
    }

    // Validate the Refresh token
    const decoded = verifyJwt<{ sub: string }>(
      refresh_token,
      'refreshTokenPublicKey'
    );

    if (!decoded) {
      throw new TRPCError({ code: 'FORBIDDEN', message });
    }

    // Check if the user has a valid session
    const session = await redisClient.get(decoded.sub);
    if (!session) {
      throw new TRPCError({ code: 'FORBIDDEN', message });
    }

    // Check if the user exist
    const user = await findUserById(JSON.parse(session)._id);

    if (!user) {
      throw new TRPCError({ code: 'FORBIDDEN', message });
    }

    // Sign new access token
    const access_token = signJwt({ sub: user._id }, 'accessTokenPrivateKey', {
      expiresIn: `${customConfig.accessTokenExpiresIn}m`,
    });

    // Send the access token as cookie
    ctx.res.cookie('access_token', access_token, accessTokenCookieOptions);
    ctx.res.cookie('logged_in', true, {
      ...accessTokenCookieOptions,
      httpOnly: false,
    });

    // Send response
    return {
      status: 'success',
      access_token,
    };
  } catch (err: any) {
    throw err;
  }
};

Logout User Controller

Finally, let’s create a handler to log out the user on both the tRPC client and server.

To log out the user on the tRPC client, we will return expired cookies to the client.

packages/server/src/controllers/auth.controller.ts


// Imports [...]

// Cookie options [...]

// Register a new user

// Login a user

// Refresh tokens handler

// Logout handler
const logout = ({ ctx }: { ctx: Context }) => {
  ctx.res.cookie('access_token', '', { maxAge: -1 });
  ctx.res.cookie('refresh_token', '', { maxAge: -1 });
  ctx.res.cookie('logged_in', '', {
    maxAge: -1,
  });
};
export const logoutHandler = async ({ ctx }: { ctx: Context }) => {
  try {
    const user = ctx.user;
    await redisClient.del(user?._id.toString());
    logout({ ctx });
    return { status: 'success' };
  } catch (err: any) {
    throw err;
  }
};

Complete Code for the Authentication Controllers

packages/server/src/controllers/auth.controller.ts


import { TRPCError } from '@trpc/server';
import { CookieOptions } from 'express';
import { Context } from '../app';
import customConfig from '../config/default';
import { CreateUserInput, LoginUserInput } from '../schema/user.schema';
import {
  createUser,
  findUser,
  findUserById,
  signToken,
} from '../services/user.service';
import redisClient from '../utils/connectRedis';
import { signJwt, verifyJwt } from '../utils/jwt';
// Imports [...]

// Cookie options [...]
const cookieOptions: CookieOptions = {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax',
};

const accessTokenCookieOptions = {
  ...cookieOptions,
  expires: new Date(Date.now() + customConfig.accessTokenExpiresIn * 60 * 1000),
};

const refreshTokenCookieOptions = {
  ...cookieOptions,
  expires: new Date(
    Date.now() + customConfig.refreshTokenExpiresIn * 60 * 1000
  ),
};

// Register a new user
export const registerHandler = async ({
  input,
}: {
  input: CreateUserInput;
}) => {
  try {
    const user = await createUser({
      email: input.email,
      name: input.name,
      password: input.password,
      photo: input.photo,
    });

    return {
      status: 'success',
      data: {
        user,
      },
    };
  } catch (err: any) {
    if (err.code === 11000) {
      throw new TRPCError({
        code: 'CONFLICT',
        message: 'Email already exists',
      });
    }
    throw err;
  }
};

// Login a user
export const loginHandler = async ({
  input,
  ctx,
}: {
  input: LoginUserInput;
  ctx: Context;
}) => {
  try {
    // Get the user from the collection
    const user = await findUser({ email: input.email });

    // Check if user exist and password is correct
    if (
      !user ||
      !(await user.comparePasswords(user.password, input.password))
    ) {
      throw new TRPCError({
        code: 'BAD_REQUEST',
        message: 'Invalid email or password',
      });
    }

    // Create the Access and refresh Tokens
    const { access_token, refresh_token } = await signToken(user);

    // Send Access Token in Cookie
    ctx.res.cookie('access_token', access_token, accessTokenCookieOptions);
    ctx.res.cookie('refresh_token', refresh_token, refreshTokenCookieOptions);
    ctx.res.cookie('logged_in', true, {
      ...accessTokenCookieOptions,
      httpOnly: false,
    });

    // Send Access Token
    return {
      status: 'success',
      access_token,
    };
  } catch (err: any) {
    throw err;
  }
};

// Refresh tokens handler
export const refreshAccessTokenHandler = async ({ ctx }: { ctx: Context }) => {
  try {
    // Get the refresh token from cookie
    const refresh_token = ctx.req.cookies.refresh_token as string;

    const message = 'Could not refresh access token';
    if (!refresh_token) {
      throw new TRPCError({ code: 'FORBIDDEN', message });
    }

    // Validate the Refresh token
    const decoded = verifyJwt<{ sub: string }>(
      refresh_token,
      'refreshTokenPublicKey'
    );

    if (!decoded) {
      throw new TRPCError({ code: 'FORBIDDEN', message });
    }

    // Check if the user has a valid session
    const session = await redisClient.get(decoded.sub);
    if (!session) {
      throw new TRPCError({ code: 'FORBIDDEN', message });
    }

    // Check if the user exist
    const user = await findUserById(JSON.parse(session)._id);

    if (!user) {
      throw new TRPCError({ code: 'FORBIDDEN', message });
    }

    // Sign new access token
    const access_token = signJwt({ sub: user._id }, 'accessTokenPrivateKey', {
      expiresIn: `${customConfig.accessTokenExpiresIn}m`,
    });

    // Send the access token as cookie
    ctx.res.cookie('access_token', access_token, accessTokenCookieOptions);
    ctx.res.cookie('logged_in', true, {
      ...accessTokenCookieOptions,
      httpOnly: false,
    });

    // Send response
    return {
      status: 'success',
      access_token,
    };
  } catch (err: any) {
    throw err;
  }
};

// Logout handler
const logout = ({ ctx }: { ctx: Context }) => {
  ctx.res.cookie('access_token', '', { maxAge: -1 });
  ctx.res.cookie('refresh_token', '', { maxAge: -1 });
  ctx.res.cookie('logged_in', '', {
    maxAge: -1,
  });
};
export const logoutHandler = async ({ ctx }: { ctx: Context }) => {
  try {
    const user = ctx.user;
    await redisClient.del(user?._id.toString());
    logout({ ctx });
    return { status: 'success' };
  } catch (err: any) {
    throw err;
  }
};

Create a User Controller

Let’s create a getMe handler to return the authenticated user’s information we will be storing in the tRPC context.

Create a packages/server/src/controllers/user.controller.ts file and add the following code:

packages/server/src/controllers/user.controller.ts


import { TRPCError } from '@trpc/server';
import { Context } from '../app';

export const getMeHandler = ({ ctx }: { ctx: Context }) => {
  try {
    const user = ctx.user;
    return {
      status: 'success',
      data: {
        user,
      },
    };
  } catch (err: any) {
    throw new TRPCError({
      code: 'INTERNAL_SERVER_ERROR',
      message: err.message,
    });
  }
};

Creating the Authentication Middleware

tRPC allows us to add middleware(s) to a whole router with the middleware() method.

This middleware will be called on protected routes to ensure that a valid access token was included in the request.

packages/server/src/middleware/deserializeUser.ts


import { TRPCError } from '@trpc/server';
import * as trpcExpress from '@trpc/server/adapters/express';
import { findUserById } from '../services/user.service';
import redisClient from '../utils/connectRedis';
import { verifyJwt } from '../utils/jwt';

export const deserializeUser = async ({
  req,
  res,
}: trpcExpress.CreateExpressContextOptions) => {
  try {
    // Get the token
    let access_token;
    if (
      req.headers.authorization &&
      req.headers.authorization.startsWith('Bearer')
    ) {
      access_token = req.headers.authorization.split(' ')[1];
    } else if (req.cookies.access_token) {
      access_token = req.cookies.access_token;
    }

    const notAuthenticated = {
      req,
      res,
      user: null,
    };

    if (!access_token) {
      return notAuthenticated;
    }

    // Validate Access Token
    const decoded = verifyJwt<{ sub: string }>(
      access_token,
      'accessTokenPublicKey'
    );

    if (!decoded) {
      return notAuthenticated;
    }

    // Check if user has a valid session
    const session = await redisClient.get(decoded.sub);

    if (!session) {
      return notAuthenticated;
    }

    // Check if user still exist
    const user = await findUserById(JSON.parse(session)._id);

    if (!user) {
      return notAuthenticated;
    }

    return {
      req,
      res,
      user: { ...user, id: user._id.toString() },
    };
  } catch (err: any) {
    throw new TRPCError({
      code: 'INTERNAL_SERVER_ERROR',
      message: err.message,
    });
  }
};

Creating the Routers in the App File

Install the following packages:


yarn add cookie-parser && yarn add -D @types/cookie-parser morgan @types/morgan

  • cookie-parser – Parses the Cookie header and populate req.cookies with an object keyed by the cookie names.
  • morgan – for logging the tRPC requests in the terminal

Now update the packages/server/src/app.ts file to add the authRouter and userRouter to the app router.

Also, you need to add the cookie parser middleware to the middleware stack to enable it to parse the cookies in the request headers.

packages/server/src/app.ts


import path from "path";
import dotenv from "dotenv";
import express from "express";
import morgan from "morgan";
import cors from "cors";
import cookieParser from "cookie-parser";
import { inferAsyncReturnType, initTRPC, TRPCError } from "@trpc/server";
import * as trpcExpress from "@trpc/server/adapters/express";
import { createUserSchema, loginUserSchema } from "./schema/user.schema";
import {
  loginHandler,
  logoutHandler,
  refreshAccessTokenHandler,
  registerHandler,
} from "./controllers/auth.controller";
import { getMeHandler } from "./controllers/user.controller";
import { deserializeUser } from "./middleware/deserializeUser";
import connectDB from "./utils/connectDB";
import customConfig from "./config/default";

dotenv.config({ path: path.join(__dirname, "./.env") });

const createContext = ({ req, res }: trpcExpress.CreateExpressContextOptions) =>
  deserializeUser({ req, res });

export type Context = inferAsyncReturnType<typeof createContext>;

export const t = initTRPC.context<Context>().create();

const authRouter = t.router({
  registerUser: t.procedure
    .input(createUserSchema)
    .mutation(({ input }) => registerHandler({ input })),
  loginUser: t.procedure
    .input(loginUserSchema)
    .mutation(({ input, ctx }) => loginHandler({ input, ctx })),
  logoutUser: t.procedure.mutation(({ ctx }) => logoutHandler({ ctx })),
  refreshToken: t.procedure.query(({ ctx }) =>
    refreshAccessTokenHandler({ ctx })
  ),
});

const isAuthorized = t.middleware(({ ctx, next }) => {
  if (!ctx.user) {
    throw new TRPCError({
      code: "UNAUTHORIZED",
      message: "You must be logged in to access this resource",
    });
  }
  return next();
});

const isAuthorizedProcedure = t.procedure.use(isAuthorized);

const userRouter = t.router({
  sayHello: t.procedure.query(async () => {
    const message = "Welcome to tRPC with React and Node";
    return { message };
  }),
  getMe: isAuthorizedProcedure.query(({ ctx }) => getMeHandler({ ctx })),
});

const appRouter = t.mergeRouters(authRouter, userRouter);

export type AppRouter = typeof appRouter;

const app = express();
if (process.env.NODE_ENV !== "production") app.use(morgan("dev"));

app.use(cookieParser());
app.use(
  cors({
    origin: [customConfig.origin],
    credentials: true,
  })
);
app.use(
  "/api/trpc",
  trpcExpress.createExpressMiddleware({
    router: appRouter,
    createContext,
  })
);

const port = customConfig.port;
app.listen(port, () => {
  console.log(`🚀 Server listening on port ${port}`);

  // CONNECT DB
  connectDB();
});

Conclusion

Congrats on reaching the end. In this article, you learned how to implement access and refresh tokens in a tRPC API using Node.js, Express, MongoDB, Typegoose, Docker, and Redis.

tRPC, Node.js, MongoDB, and Express Source Code

Check out the: