TypeGraphQL is a Node.js framework for building GraphQL APIs using TypeScript classes and decorators. This article will teach you how to build a GraphQL API to implement JWT Authentication using TypeGraphQL, Node.js, MongoDB, Redis, and Apollo Server.

Related Articles:

TypeGraphQL & MongoDB GraphQL API JWT Authentication

Node.js TypeGraph API JWT Authentication Overview

Initialize a Typescript Node.js Project

To begin, let’s initialize a Typescript Node.js project with the following commands:


mkdir node_typegraphql_api # create project folder
cd node_typegraphql_api
yarn init -y && yarn add -D typescript @types/node && npx tsc --init # initialize a node project with Typescript

Next, replace the content of the tsconfig.json file with the following TypeScript configurations:

tsconfig.json


{
  "compilerOptions": {
    "target": "es2018",
    "lib": ["ES2018", "ESNext.AsyncIterable"],
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "strictPropertyInitialization": false,
    "skipLibCheck": true
  }
}

You need to make sure the experimentalDecorators and emitDecoratorMetadata are set to true. Also, strictPropertyInitialization should be set to false to enable us to use the Typegoose decorators without errors.

Setup MongoDB and Redis Docker Containers

To get the Redis and MongoDB servers running on our machine, we gonna use Docker and Docker-compose since I find it a lot easier.

I’m going to assume you already have Docker and Docker-compose installed on your machine.

Now create a docker-compose.yml file in the root directory and add the Docker-compose configurations to set up and run the MongoDB and Redis containers on your machine.

docker-compose.yml


version: '3.8'
services:
  mongo:
    image: mongo
    container_name: mongodb
    ports:
      - '6000:27017'
    volumes:
      - mongodb:/data/db
    env_file:
      - ./.env
    environment:
      MONGO_INITDB_ROOT_USERNAME: ${MONGO_INITDB_ROOT_USERNAME}
      MONGO_INITDB_ROOT_PASSWORD: ${MONGO_INITDB_ROOT_PASSWORD}
      MONGO_INITDB_DATABASE: ${MONGO_INITDB_DATABASE}
  redis:
    image: redis:latest
    container_name: redis
    ports:
      - '6379:6379'
    volumes:
      - redis:/data
volumes:
  redis:
  mongodb:


To view the data stored in the MongoDB and Redis databases, we’ll use the MySQL VS Code extension.

vs code mysql extension
MySQL VS Code extension

Next, let’s set up environment variables to provide the credentials needed by the MongoDB Docker image.

Setup Environment Variables

The security of our application is very important so we need a way to securely store sensitive information such as API Keys, passwords, etc.

In this mini-project, we’ll store all the sensitive information in a .env file and load them using the dotenv package.

In addition, we’ll use the config library to retrieve the environment variables. Doing it this way will enable us to provide the Typescript types for the environment variables.


npm i config dotenv && npm i -D @types/config
# or
yarn add config dotenv && yarn add -D @types/config

.env


PORT=8000
NODE_ENV=development

MONGO_INITDB_ROOT_USERNAME=admin
MONGO_INITDB_ROOT_PASSWORD=password123
MONGO_INITDB_DATABASE=node_typegraphql

MONGODB_LOCAL_URI=mongodb://admin:password123@localhost:6000/node_typegraphql?authSource=admin

ACCESS_TOKEN_PRIVATE_KEY=LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT1FJQkFBSkJBTUJSTHZIZkw1ei9NbkZyZWlxRzZ4eG10K1dFdERMZ0d4S3pyaW8vZzhYVTRyMS91a1FBClJjaUlteXcvSlh3djdMQldKMHYraGlKTXI5SVh4UUx2L2lrQ0F3RUFBUUpBQ0RMdDEyMzJiN0VKaTRCVGd3Q2gKZ1dKM3NKZEp2Mm1DZmZlZkV6b0YraWNZV1NuL2lEb1dJSnZJaG9LUFluenpwSE5HTDFScE82WUF4eXJ4RDFHVApJUUloQU9vd0EydjkxWk1lVVhwZk1IY0tmZmNRMXl6KzNRZXB2SjZOdDBTUjlkczFBaUVBMGpyTzR6eE9aaStoCklEbHhHS0lLMHR4QVA0T3ZSeC9Cc0dkblF0aDBnYVVDSUhNaFZaMVN0aHZVak9QblJqRlB4Q3VoYU5lakdGQzcKSHBLb1F3Ly8zZWw5QWlCeFF0V3JmeFlOMzZOREpTOVZRaGZxdWxheTRWTlJVajZidDFuZW5aZEhNUUlnVHQ3ZApEREhuYjhlT1R2MzhMV0c0SEhRa1c2YUZhM1Y4aU1xQ0VUZDdMV1E9Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t
ACCESS_TOKEN_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZ3d0RRWUpLb1pJaHZjTkFRRUJCUUFEU3dBd1NBSkJBTUJSTHZIZkw1ei9NbkZyZWlxRzZ4eG10K1dFdERMZwpHeEt6cmlvL2c4WFU0cjEvdWtRQVJjaUlteXcvSlh3djdMQldKMHYraGlKTXI5SVh4UUx2L2lrQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQ==

REFRESH_TOKEN_PRIVATE_KEY=LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT0FJQkFBSkFVOThRakVkZXVySnJJRjgvOEVGMldOZGFPR3pXMlE2RmlTdy9XUmMxQUVKV1lqWnVGbS9jCkFQNnIyOURWU2R2Umw5bDJSRjkvTEQ5R0xHeUxKWEgrMXdJREFRQUJBa0F3cVArSjA3S1RNUWJVTGs4RFRvZWkKUGJkN2V6SWZscnhFL0tYNFEyMmRxelRHRHNYemR1WWRyUUJvOTB3bGVoMUFBRlN4eEdBaWN5TjY3T2dQbVBJWgpBaUVBblp0Sy9sR2lxZ0REeHZpY0kxQXVucllYQURuckRtQW53QVE5aUFpNlVNVUNJUUNJTzJTU2ppeENFWEhvCkxoaXNOUE00cTlpd0lXZ0laTFZhb3N4V09Zd1M2d0lnTGtUdzkwaXdJSWlvOFRqN1hjS2tiU080RFBEeitQeHgKcndXUHF1Z2xDcUVDSUJnUUpadWRjVVBhUVB4NTUvSERDSk1pQjR4VTJrTmhSb2RUNmpQd3hrVXhBaUJCa1VDbworemwzQm8yNmIrd3BhQ29IZ0VZSEozZWExUy94Mk5vV1BqSEZQUT09Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t
REFRESH_TOKEN_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZzd0RRWUpLb1pJaHZjTkFRRUJCUUFEU2dBd1J3SkFVOThRakVkZXVySnJJRjgvOEVGMldOZGFPR3pXMlE2RgppU3cvV1JjMUFFSldZalp1Rm0vY0FQNnIyOURWU2R2Umw5bDJSRjkvTEQ5R0xHeUxKWEgrMXdJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0t


Feel free to add the .env file to your .gitignore to omit it from your Git commits.

Now create a config/default.ts file in the root directory and add the following code.

config/default.ts


export default {
  costFactor: 12,
  accessTokenExpiresIn: 15,
  refreshTokenExpiresIn: 60,
};

Also, create config/custom-environment-variables.ts and add the following code to load the environment variables we added to the .env file.

config/custom-environment-variables.ts


export default {
  PORT: 'PORT',
  dbUri: 'MONGODB_LOCAL_URI',

  accessTokenPrivateKey: 'ACCESS_TOKEN_PRIVATE_KEY',
  accessTokenPublicKey: 'ACCESS_TOKEN_PUBLIC_KEY',
  refreshTokenPrivateKey: 'REFRESH_TOKEN_PRIVATE_KEY',
  refreshTokenPublicKey: 'REFRESH_TOKEN_PUBLIC_KEY',
};


Now that we have the environment variables, and docker-compose configured correctly, let’s start the Redis and MongoDB Docker containers.

Run this command in your terminal to start both the MongoDB and Redis containers:


docker-compose up -d

Connecting the Redis and MongoDB Instances to the App

To connect to the MongoDB and Redis servers, you need to make sure both containers are running with this command.


docker ps

Connecting to the MongoDB Server

Run this command to install Mongoose:


yarn add mongoose
# or 
npm install mongoose

Now create an src folder in the root directory then add a utils/connectDB.ts file and paste the code below into it.

src/utils/connectDB.ts


import mongoose from 'mongoose';
import config from 'config';

const localUri = config.get<string>('dbUri');

async function connectDB() {
  try {
    await mongoose.connect(localUri);
    console.log('? Database connected successfully');
  } catch (error: any) {
    console.log(error.message);
    setTimeout(connectDB, 5000);
  }
}

export default connectDB;

Connecting to the Redis Server

To easily connect to the Redis server, we gonna use the Redis Node.js package:


yarn add redis
# or 
npm install redis

Create a src/utils/connectRedis.ts file and add the following code:

src/utils/connectRedis.ts


import { createClient } from 'redis';

const redisUrl = 'redis://localhost:6379';

const redisClient = createClient({
  url: redisUrl,
});

const connectRedis = async () => {
  try {
    await redisClient.connect();
  } catch (error: any) {
    setInterval(connectRedis, 5000);
  }
};

connectRedis();

redisClient.on('connect', () =>
  console.log('? Redis client connected successfully')
);

redisClient.on('error', (err) => console.error(err));

export default redisClient;

DefiningTypeGraphQL and Typegoose Schemas

The GraphQL schema also known as type definitions refers to the structure of the queries and mutations.

Run the following command to install the necessary packages:


yarn add type-graphql @typegoose/typegoose class-validator bcryptjs && yarn add -D @types/bcryptjs
# or 
npm install type-graphql @typegoose/typegoose class-validator bcryptjs && npm install -D @types/bcryptjs

  • type-graphql – is a Node.js framework for building GraphQL APIs based on Typescript classes and decorators.
  • class-validator – a validation library that supports both decorator and non-decorator syntax.
  • bcryptjs– for hashing strings
  • @typegoose/typegoose– a package for writing Mongoose schemas using Typescript classes and decorators.

In the src folder, create a models/user.model.ts file and add the following imports:

src/models/user.model.ts


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

Defining the Typegoose Schema

Typegoose is a library that acts as a wrapper for Mongoose models and makes heavy use of decorators to ensure that the models become type-rich with TypeScript.

src/models/user.model.ts


// Imports

@pre<User>('save', async function (next) {
  if (!this.isModified('password')) return next();

  this.password = await bcrypt.hash(
    this.password,
    config.get<number>('costFactor')
  );
  this.passwordConfirm = undefined;
  return next();
})
@ModelOptions({
  schemaOptions: {
    timestamps: true,
  },
  options: {
    allowMixed: Severity.ALLOW,
  },
})
@index({ email: 1 })
export class User {
  readonly _id: string;

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

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

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

  @prop({ required: true, select: false })
  password: string;

  @prop({ required: true })
  passwordConfirm: string | undefined;

  @prop({ default: 'default.jpeg' })
  photo: string;

  @prop({ default: true, select: false })
  verified: boolean;

  static async comparePasswords(
    hashedPassword: string,
    candidatePassword: string
  ) {
    return await bcrypt.compare(candidatePassword, hashedPassword);
  }
}

const UserModel = getModelForClass<typeof User>(User);
export default UserModel;

Here is a breakdown of what I did above:

  • First and foremost, I created and exported a User class and added the necessary properties using the Typegoose decorators.
  • Next, I added an ascending index to the email field since we’ll query the database using the user’s email.
  • Next, I used the Typegoose @ModelOptions({}) decorator on the User class to make sure the createdAt and updatedAt fields are added automatically by Mongoose.
  • Then I used the Typegoose pre-save hook decorator to hash the plain password before saving the document to the database.
  • Lastly, I used the getModelForClass() function to export the Mongoose model from the user class.

Defining the TypeGraphQL Schemas

Create a src/schemas/user.schema.ts file and add the following code snippets:

src/schemas/user.schema.ts


import { Field, InputType, ObjectType } from 'type-graphql';
import { IsEmail, MaxLength, MinLength } from 'class-validator';

@InputType()
export class SignUpInput {
  @Field(() => String)
  name: string;

  @IsEmail()
  @Field(() => String)
  email: string;

  @MinLength(8, { message: 'Password must be at least 8 characters long' })
  @MaxLength(32, { message: 'Password must be at most 32 characters long' })
  @Field(() => String)
  password: string;

  @Field(() => String)
  passwordConfirm: string | undefined;

  @Field(() => String)
  photo: string;
}

@InputType()
export class LoginInput {
  @IsEmail()
  @Field(() => String)
  email: string;

  @MinLength(8, { message: 'Invalid email or password' })
  @MaxLength(32, { message: 'Invalid email or password' })
  @Field(() => String)
  password: string;
}

@ObjectType()
export class UserData {
  @Field(() => String)
  readonly _id: string;

  @Field(() => String, { nullable: true })
  readonly id: string;

  @Field(() => String)
  name: string;

  @Field(() => String)
  email: string;

  @Field(() => String)
  role: string;

  @Field(() => String)
  photo: string;

  @Field(() => Date)
  createdAt: Date;

  @Field(() => Date)
  updatedAt: Date;
}

@ObjectType()
export class UserResponse {
  @Field(() => String)
  status: string;

  @Field(() => UserData)
  user: UserData;
}

@ObjectType()
export class LoginResponse {
  @Field(() => String)
  status: string;

  @Field(() => String)
  access_token: string;
}

Here we are defining both the input and object types using the @InputType() and @ObjectType() decorators.

The input type describes the structure of the data the GraphQL server will receive whereas the object type describes the data structure the server will return to the client.

In the input classes, you will notice we specified all the fields except the ID because it will be automatically generated by MongoDB whenever a new document is added to the database.

Create an Error Controller

Since we are done creating the models and schemas, let’s create an error handler to return the right Apollo Error code and message to the client.

Out of the box, the Apollo server library comes with an error class that we can leverage to send the appropriate errors to the client.

src/controllers/error.controller.ts


import { ValidationError } from 'apollo-server-core';

const handleCastError = (error: any) => {
  const message = `Invalid ${error.path}: ${error.value}`;
  throw new ValidationError(message);
};

const handleValidationError = (error: any) => {
  const message = Object.values(error.errors).map((el: any) => el.message);
  throw new ValidationError(`Invalid input: ${message.join(', ')}`);
};

const errorHandler = (err: any) => {
  if (err.name === 'CastError') handleCastError(err);
  if (err.name === 'ValidationError') handleValidationError(err);
  throw err;
};

export default errorHandler;

In the above code, we are catching the validation errors that will be returned by MongoDB to prevent our application from crashing.

Create Utility Functions to Sign and Verify JWTs

A JSON Web Token consists of three parts a header, payload, and signature separated by dots. The signature is a combination of the encoded header, the encoded payload, a secret, and the algorithm specified in the header.

To add an additional layer of security to the authentication flow, we will use Redis to store the user’s session and expire it after a given time.

Run this command to install the JSON Web Token package:


yarn add jsonwebtoken && yarn add -D @types/jsonwebtoken
# or 
npm install jsonwebtoken && npm install -D @types/jsonwebtoken

To prevent hackers from easily accessing and manipulating the access and refresh tokens, we will store them in HTTPOnly cookies.

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 Private and Public Keys

To make your life easier, I already added the private and public keys to the .env.example file but you can follow these steps to generate them by yourself.

Step 1: Go to this website and click on the “Generate New Keys” button to generate both the private and public keys.

Step 2: Go to this website to encode the private and public keys into Base64. Encoding the keys will ensure that you do not get unnecessary warnings in the terminal when building the Docker containers.

Step 3: Add the encoded private and public keys to the ACCESS_TOKEN_PUBLIC_KEY and ACCESS_TOKEN_PRIVATE_KEY variables in the .env file respectively.

Repeat the above process for the refresh token

With that out of the way, let’s create two helper functions to sign and verify the JWTs.

In each of the utility functions, you’ll notice that we decoded the private and public keys back to ASCII strings before passing them to the jsonwebtoken functions.

src/utils/jwt.ts


import jwt, { SignOptions } from 'jsonwebtoken';
import config from 'config';

export const signJwt = (
  payload: Object,
  keyName: 'accessTokenPrivateKey' | 'refreshTokenPrivateKey',
  options?: SignOptions
) => {
  const privateKey = Buffer.from(
    config.get<string>(keyName),
    'base64'
  ).toString('ascii');

  return jwt.sign(payload, privateKey, {
    ...(options && options),
    algorithm: 'RS256',
  });
};

export const verifyJwt = <T>(
  token: string,
  keyName: 'accessTokenPublicKey' | 'refreshTokenPublicKey'
): T | null => {
  const publicKey = Buffer.from(config.get<string>(keyName), 'base64').toString(
    'ascii'
  );

  try {
    return jwt.verify(token, publicKey, {
      algorithms: ['RS256'],
    }) as T;
  } catch (error) {
    return null;
  }
};

Creating an Authentication Guard

To begin, let’s install the required Apollo server dependencies and Express. Also, we need to install the cookie-parser package to enable us to parse the cookies in the request headers.


yarn add apollo-server-core apollo-server-express express cookie-parser && yarn add -D @types/cookie-parser
# or
npm install apollo-server-core apollo-server-express express cookie-parser && npm install -D @types/cookie-parser

Now let’s create a middleware to serve as an authentication guard for the protected routes.

src/middleware/deserializeUser.ts


import { AuthenticationError, ForbiddenError } from 'apollo-server-core';
import { Request } from 'express';
import errorHandler from '../controllers/error.controller';
import UserModel from '../models/user.model';
import redisClient from '../utils/connectRedis';
import { verifyJwt } from '../utils/jwt';

const deserializeUser = async (req: Request) => {
  try {
    // Get the access 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) {
      const { access_token: token } = req.cookies;
      access_token = token;
    }

    if (!access_token) throw new AuthenticationError('No access token found');

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

    if (!decoded) throw new AuthenticationError('Invalid access token');

    // Check if the session is valid
    const session = await redisClient.get(decoded.userId);

    if (!session) throw new ForbiddenError('Session has expired');

    // Check if user exist
    const user = await UserModel.findById(JSON.parse(session)._id).select(
      '+verified'
    );

    if (!user || !user.verified) {
      throw new ForbiddenError(
        'The user belonging to this token no logger exist'
      );
    }

    return user;
  } catch (error: any) {
    errorHandler(error);
  }
};

export default deserializeUser;

Here is a summary of the above code:

  • We first retrieved the access token from either the req.cookies object or the Authorization header and assign it to a variable.
  • Next, we validated the access token and extracted the payload stored in it. The payload in this case is the user’s ID.
  • We then used the extracted user’s ID to check if that user has a valid session in the Redis database.
  • Next, we checked to see if the user still exists in the MongoDB database and has been verified.
  • Finally, we returned the user document assuming there wasn’t any error.

Create the Authentication Services

Now that we have our models, schemas, and authentication guard, let’s create the authentication services responsible for logging in the user, registering a new user, requesting a new access token, and logging out the user.

Before that, let’s define the cookie options and a function to sign both the access and refresh tokens.

src/services/user.service.ts


import {
  AuthenticationError,
  ForbiddenError,
  ValidationError,
} from 'apollo-server-core';
import config from 'config';
import { CookieOptions } from 'express';
import errorHandler from '../controllers/error.controller';
import deserializeUser from '../middleware/deserializeUser';
import UserModel, { User } from '../models/user.model';
import { LoginInput } from '../schemas/user.schema';
import { Context } from '../types/context';
import redisClient from '../utils/connectRedis';
import { signJwt, verifyJwt } from '../utils/jwt';

const accessTokenExpiresIn = config.get<number>('accessTokenExpiresIn');
const refreshTokenExpiresIn = config.get<number>('refreshTokenExpiresIn');

const cookieOptions: CookieOptions = {
  httpOnly: true,
  // domain: 'localhost',
  sameSite: 'none',
  secure: true,
};

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

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

if (process.env.NODE_ENV === 'production') cookieOptions.secure = true;

async function findByEmail(email: string): Promise<User | null> {
  return UserModel.findOne({ email }).select('+password');
}

function signTokens(user: User) {
  const userId: string = user._id.toString();
  const access_token = signJwt({ userId }, 'accessTokenPrivateKey', {
    expiresIn: `${accessTokenExpiresIn}m`,
  });

  const refresh_token = signJwt({ userId }, 'refreshTokenPrivateKey', {
    expiresIn: `${refreshTokenExpiresIn}m`,
  });

  redisClient.set(userId, JSON.stringify(user), {
    EX: refreshTokenExpiresIn * 60,
  });

  return { access_token, refresh_token };
}

Register User Service

In the Typegoose schema definition, we added a unique constraint to the email field to ensure that no two users end up with the same.

So MongoDB will return a special error with a 11000 code indicating that a user with that email address already exists in the database.

src/services/user.service.ts


// imports

// Cookie Options

// Sign JWT Tokens

export default class UserService {
  // Register User
  async signUpUser(input: Partial<User>) {
    try {
      const user = await UserModel.create(input);
      return {
        status: 'success',
        user,
      };
    } catch (error: any) {
      if (error.code === 11000)
        return new ValidationError('Email already exists');
      errorHandler(error);
    }
  }

}

Login User Service

Since we have written the code to register a user, let’s create the service to sign in the registered user.

After the user is authenticated, the server will then return access and refresh token cookies to the client or browser.

src/services/user.service.ts


// imports

// Cookie Options

// Sign JWT Tokens

export default class UserService {
  // Register User

  // Login User
  async loginUser(input: LoginInput, { res }: Context) {
    try {
      const message = 'Invalid email or password';
      // 1. Find user by email
      const user = await findByEmail(input.email);

      if (!user) {
        return new AuthenticationError(message);
      }

      // 2. Compare passwords
      if (!(await UserModel.comparePasswords(user.password, input.password))) {
        return new AuthenticationError(message);
      }

      // 3. Sign JWT Tokens
      const { access_token, refresh_token } = signTokens(user);

      // 4. Add Tokens to Context
      res.cookie('access_token', access_token, accessTokenCookieOptions);
      res.cookie('refresh_token', refresh_token, refreshTokenCookieOptions);
      res.cookie('logged_in', 'true', {
        ...accessTokenCookieOptions,
        httpOnly: false,
      });

      return {
        status: 'success',
        access_token,
      };
    } catch (error: any) {
      errorHandler(error);
    }
  }
}

Get Logged-in User Service

Next, let’s create a service that will be called to return the currently logged-in user’s information.

src/services/user.service.ts


// imports

// Cookie Options

// Sign JWT Tokens

export default class UserService {
  // Register User

  // Login User

  // Get Currently Logged In User
  async getMe({ req, res, deserializeUser }: Context) {
    try {
      const user = await deserializeUser(req);
      return {
        status: 'success',
        user,
      };
    } catch (error: any) {
      errorHandler(error);
    }
  }
}

Refresh Access Token Service

src/services/user.service.ts


// imports

// Cookie Options

// Sign JWT Tokens

export default class UserService {
  // Register User

  // Login User

  // Get Currently Logged In User

  // Refresh Access Token
  async refreshAccessToken({ req, res }: Context) {
    try {
      // Get the refresh token
      const { refresh_token } = req.cookies;

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

      if (!decoded) {
        throw new ForbiddenError('Could not refresh access token');
      }

      // Check if user's session is valid
      const session = await redisClient.get(decoded.userId);

      if (!session) {
        throw new ForbiddenError('User session has expired');
      }

      // Check if user exist and is verified
      const user = await UserModel.findById(JSON.parse(session)._id).select(
        '+verified'
      );

      if (!user || !user.verified) {
        throw new ForbiddenError('Could not refresh access token');
      }

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

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

      return {
        status: 'success',
        access_token,
      };
    } catch (error) {
      errorHandler(error);
    }
  }
}

Logout User Service

To log out a user, the server will send expired cookies to the user’s browser and also delete the user’s session from the Redis database.

src/services/user.service.ts


// imports

// Cookie Options

// Sign JWT Tokens

export default class UserService {
  // Register User

  // Login User

  // Get Currently Logged In User

  // Refresh Access Token

  // Logout User
  async logoutUser({ req, res }: Context) {
    try {
      const user = await deserializeUser(req);

      // Delete the user's session
      await redisClient.del(String(user?._id));

      // Logout user
      res.cookie('access_token', '', { maxAge: -1 });
      res.cookie('refresh_token', '', { maxAge: -1 });
      res.cookie('logged_in', '', { maxAge: -1 });

      return true;
    } catch (error) {
      errorHandler(error);
    }
  }
}

Complete Code for the User Services

src/services/user.service.ts


import {
  AuthenticationError,
  ForbiddenError,
  ValidationError,
} from 'apollo-server-core';
import config from 'config';
import { CookieOptions } from 'express';
import errorHandler from '../controllers/error.controller';
import deserializeUser from '../middleware/deserializeUser';
import UserModel, { User } from '../models/user.model';
import { LoginInput } from '../schemas/user.schema';
import { Context } from '../types/context';
import redisClient from '../utils/connectRedis';
import { signJwt, verifyJwt } from '../utils/jwt';

// Cookie Options
const accessTokenExpiresIn = config.get<number>('accessTokenExpiresIn');
const refreshTokenExpiresIn = config.get<number>('refreshTokenExpiresIn');

const cookieOptions: CookieOptions = {
  httpOnly: true,
  // domain: 'localhost',
  sameSite: 'none',
  secure: true,
};

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

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

if (process.env.NODE_ENV === 'production') cookieOptions.secure = true;

async function findByEmail(email: string): Promise<User | null> {
  return UserModel.findOne({ email }).select('+password');
}

// Sign JWT Tokens
function signTokens(user: User) {
  const userId: string = user._id.toString();
  const access_token = signJwt({ userId }, 'accessTokenPrivateKey', {
    expiresIn: `${accessTokenExpiresIn}m`,
  });

  const refresh_token = signJwt({ userId }, 'refreshTokenPrivateKey', {
    expiresIn: `${refreshTokenExpiresIn}m`,
  });

  redisClient.set(userId, JSON.stringify(user), {
    EX: refreshTokenExpiresIn * 60,
  });

  return { access_token, refresh_token };
}

export default class UserService {
  // Register User
  async signUpUser(input: Partial<User>) {
    try {
      const user = await UserModel.create(input);
      return {
        status: 'success',
        user,
      };
    } catch (error: any) {
      if (error.code === 11000) {
        return new ValidationError('Email already exists');
      }
      errorHandler(error);
    }
  }

  // Login User
  async loginUser(input: LoginInput, { res }: Context) {
    try {
      const message = 'Invalid email or password';
      // 1. Find user by email
      const user = await findByEmail(input.email);

      if (!user) {
        return new AuthenticationError(message);
      }

      // 2. Compare passwords
      if (!(await UserModel.comparePasswords(user.password, input.password))) {
        return new AuthenticationError(message);
      }

      // 3. Sign JWT Tokens
      const { access_token, refresh_token } = signTokens(user);

      // 4. Add Tokens to Context
      res.cookie('access_token', access_token, accessTokenCookieOptions);
      res.cookie('refresh_token', refresh_token, refreshTokenCookieOptions);
      res.cookie('logged_in', 'true', {
        ...accessTokenCookieOptions,
        httpOnly: false,
      });

      return {
        status: 'success',
        access_token,
      };
    } catch (error: any) {
      errorHandler(error);
    }
  }

  // Get Currently Logged In User
  async getMe({ req, res, deserializeUser }: Context) {
    try {
      const user = await deserializeUser(req);
      return {
        status: 'success',
        user,
      };
    } catch (error: any) {
      errorHandler(error);
    }
  }

  // Refresh Access Token
  async refreshAccessToken({ req, res }: Context) {
    try {
      // Get the refresh token
      const { refresh_token } = req.cookies;

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

      if (!decoded) {
        throw new ForbiddenError('Could not refresh access token');
      }

      // Check if user's session is valid
      const session = await redisClient.get(decoded.userId);

      if (!session) {
        throw new ForbiddenError('User session has expired');
      }

      // Check if user exist and is verified
      const user = await UserModel.findById(JSON.parse(session)._id).select(
        '+verified'
      );

      if (!user || !user.verified) {
        throw new ForbiddenError('Could not refresh access token');
      }

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

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

      return {
        status: 'success',
        access_token,
      };
    } catch (error) {
      errorHandler(error);
    }
  }

  // Logout User
  async logoutUser({ req, res }: Context) {
    try {
      const user = await deserializeUser(req);

      // Delete the user's session
      await redisClient.del(String(user?._id));

      // Logout user
      res.cookie('access_token', '', { maxAge: -1 });
      res.cookie('refresh_token', '', { maxAge: -1 });
      res.cookie('logged_in', '', { maxAge: -1 });

      return true;
    } catch (error) {
      errorHandler(error);
    }
  }
}

Creating the TypeGraphQL Resolvers

Once we have our services defined, let’s create the Queries, and Mutations by creating a class and adding the required decorators.

Before that, let’s create a Typescript type for the context we will be passing down to the services.

src/types/context.ts


import { Request, Response } from 'express';
import { User } from '../models/user.model';

export type Context = {
  req: Request;
  res: Response;
  deserializeUser: (req: Request) => Promise<User | undefined>;
};

TypeGraphQL gives us the luxury to create as many resolver classes as we want to enable us to decouple the business logic and services using Dependency Injection.

src/resolvers/user.resolver.ts


import { Arg, Ctx, Mutation, Query, Resolver } from 'type-graphql';
import {
  LoginInput,
  LoginResponse,
  SignUpInput,
  UserResponse,
} from '../schemas/user.schema';
import UserService from '../services/user.service';
import { Context } from '../types/context';

@Resolver()
export default class UserResolver {
  constructor(private userService: UserService) {
    this.userService = new UserService();
  }

  @Mutation(() => UserResponse)
  async signupUser(@Arg('input') input: SignUpInput) {
    return this.userService.signUpUser(input);
  }

  @Mutation(() => LoginResponse)
  async loginUser(@Arg('input') loginInput: LoginInput, @Ctx() ctx: Context) {
    return this.userService.loginUser(loginInput, ctx);
  }

  @Query(() => UserResponse)
  async getMe(@Ctx() ctx: Context) {
    return this.userService.getMe(ctx);
  }

  @Query(() => LoginResponse)
  async refreshAccessToken(@Ctx() ctx: Context) {
    return this.userService.refreshAccessToken(ctx);
  }

  @Query(() => Boolean)
  async logoutUser(@Ctx() ctx: Context) {
    return this.userService.logoutUser(ctx);
  }
}

src/resolvers/index.ts


import UserResolver from './user.resolver';

export const resolvers = [UserResolver] as const;

Initialize the Express App

Run the following command to install cors and ts-node-dev :

  • cors – The cors package will enable the GraphQL server to accept requests from cross-origin domains.
  • ts-node-dev – this package will allow us to hot-reload the server whenever any of the required files changes.

yarn add cors && yarn add -D @types/cors ts-node-dev
# or
npm install cors && npm install -D @types/cors ts-node-dev

Create a src/app.ts file and add the following code snippets:

src/app.ts


import cookieParser from 'cookie-parser';
import express from 'express';
import cors from 'cors';

const app = express();

// MIDDLEWARE
export const corsOptions = {
  origin: [
    'https://studio.apollographql.com',
    'http://localhost:8000',
    'http://localhost:3000',
  ],
  credentials: true,
};
app.use(cookieParser());
app.use(cors(corsOptions));

export default app;

Here, you need to call the cookie-parser middleware to enable it to parse the request cookies.

Configure Apollo Server with Express

Now let’s install the reflect-metadata package to make the type reflection work and import it at the top of the entry file before we use/import type-graphql or our resolvers.


yarn add reflect-metadata graphql@15.x
# or 
npm install reflect-metadata graphql@15.x

Next, create a src/server.ts file and add the following code to set up the Apollo server with Express.

src/server.ts


import dotenv from 'dotenv';
import app, { corsOptions } from './app';
dotenv.config();
import 'reflect-metadata';
import { buildSchema } from 'type-graphql';
import { ApolloServer } from 'apollo-server-express';
import { ApolloServerPluginDrainHttpServer } from 'apollo-server-core';
import config from 'config';
import http from 'http';
import { resolvers } from './resolvers';
import connectDB from './utils/connectDB';
import deserializeUser from './middleware/deserializeUser';

async function bootstrap() {
  const httpServer = http.createServer(app);

  const schema = await buildSchema({
    resolvers,
    dateScalarMode: 'isoDate',
  });

  const server = new ApolloServer({
    schema,
    csrfPrevention: true,
    plugins: [ApolloServerPluginDrainHttpServer({ httpServer })],
    context: ({ req, res }) => ({ req, res, deserializeUser }),
  });

  // Start the server
  await server.start();

  // Apply middleware
  server.applyMiddleware({ app, cors: corsOptions });

  // Listen on port
  const port = config.get<number>('PORT') || 4000;
  await new Promise<void>((resolve) => httpServer.listen({ port }, resolve));
  console.log(
    `? Server ready at http://localhost:${port}${server.graphqlPath}`
  );

  // CONNECT MONGODB
  connectDB();

  process.on('unhandledRejection', (err: any) => {
    console.log('UNHANDLED REJECTION ?? Shutting down...');
    console.log(err);
    console.error('Error?', err.message);

    httpServer.close(async () => {
      process.exit(1);
    });
  });
}

bootstrap();

Finally, add this script to your package.json file to enable us to hot-reload the server.

package.json


{
"scripts": {
    "start": "ts-node-dev --respawn --transpile-only src/server.ts"
  }
}

Start the MongoDB and Redis Docker containers with this command:


docker-compose up -d

Start the Apollo server with this command:


yarn start
# or
npm run start

Now that we have the server up and running, hold the CTRL/CMD key and click on the Sandbox URL in the terminal to open it in the browser.

Testing the GraphQL API with Apollo Sandbox GUI

Despite setting the correct cookie headers, we need to tell the Sandbox to include the cookies along with the request.

graphql api with nodejs set sandbox settings

Next, turn on the “Include cookies” option.

graphql api with nodejs set sandbox settings allow cookies

-Sign up a new user

graphql api with nodejs signup user

-Sign in the registered user

graphql api with nodejs login the user

-Use CTRL/CMD + i to open the dev-tools to inspect the cookies sent by the GraphQL API server.

graphql api with nodejs inspect the cookies

-Get the currently logged-in user’s credentials

graphql api with nodejs get currently logged in users credentials

-Request a new access token when it expires

graphql api with nodejs refresh access token

-Logout the logged-in user

graphql node api apollo server logout user

Conclusion

With this TypeGraphQL, Apollo Server, MongoDB, Class Validator, Node.js, and Redis example in Typescript, you’ve learned how to build a GraphQL API to implement access and refresh token functionality.

GraphQL, TypeGraphQL, and MongoDB Source Code

You can find the complete source code on my GitHub page