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:
- GraphQL API with Node.js & MongoDB: JWT Authentication
- GraphQL API with Next.js & MongoDB: Access & Refresh Tokens
- Build Golang gRPC Server and Client: Access & Refresh Tokens
- Node.js + Prisma + PostgreSQL: Access & Refresh Tokens
- Golang & MongoDB: JWT Authentication and Authorization
- API with Node.js + PostgreSQL + TypeORM: JWT Authentication
- Node.js + TypeScript + MongoDB: JWT Authentication
- Node.js + TypeScript + MongoDB: JWT Refresh Token
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.
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 theUser
class to make sure thecreatedAt
andupdatedAt
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.
Next, turn on the “Include cookies” option.
-Sign up a new user
-Sign in the registered user
-Use CTRL/CMD + i
to open the dev-tools to inspect the cookies sent by the GraphQL API server.
-Get the currently logged-in user’s credentials
-Request a new access token when it expires
-Logout the logged-in 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