This article will teach you how to add JSON Web Token (JWT) Authentication to a tRPC API built with PostgreSQL, Prisma, Express, Node.js, and Redis.
When we build full-stack applications with TypeScript, we end up developing different strategies to share the static types between the frontend and backend, this approach works for a few genius developers.
Those who fall into the vast majority of people either end up increasing the complexity of their project or wasting productive hours writing unnecessary code.
Up until now, GraphQL has been the dominant library used by developers to build type-safety full-stack applications.
GraphQL was a game changer for most developers since it came with more flexibility and control.
However, the flexibility GraphQL brought to developers came at a price in the form of extra complexity and the need to learn the query language.
Due to the complexities of GraphQL, frameworks, libraries, and services started popping up to streamline the development process, and provide better patterns.
Recently, tRPC was introduced to help developers statically type their API endpoints and directly share the types between the client and server.
tRPC is lightweight, has zero dependencies, doesn’t rely on code generation, leaves a tiny client-side footprint, and has adaptors for Fastify/Express/Next.js.
tRPC API with React.js, Express, Prisma, and PostgreSQL Series:
- How to Setup tRPC API with Prisma, PostgreSQL, Node & React
- tRPC API with PostgreSQL, Prisma, Redis, & Node.js: JWT Authentication
Related Articles:
- Golang & MongoDB: JWT Authentication and Authorization
- API with Golang + MongoDB: Send HTML Emails with Gomail
- API with Golang, Gin Gonic & MongoDB: Forget/Reset Password
Prerequisites
Before we begin:
- Basic knowledge of Node.js, and Express will be helpful.
- An intermediate understanding of Prisma, and how ORMs and relational databases work will be highly beneficial.
- Have Docker and Node.js installed
Setting up the Project
We will be using Express, Prisma, PostgreSQL, and tRPC along with the Express adaptor to implement the authentication flow.
Also, we will be using PostgreSQL as the primary database and Redis to store the authenticated user’s session. Despite implementing the authentication with JSON Web Tokens, we need to include Redis to add an extra layer of security.
Follow the Project Setup tutorial to set up the tRPC project with PostgreSQL, Express and Prisma before continuing with this article.
Creating the Database Models with Prisma
Prisma is an open-source ORM (Object Relational Mapping) for Node.js and TypeScript.
At the time of writing this article, Prisma supports PostgreSQL, MySQL, SQLite, SQL Server, MongoDB, and CockroachDB – making it the dominant ORM for any kind of project.
Before we start creating the database models, change the directory into the packages/client
folder and install the following dependencies:
yarn add -D prisma && yarn add @prisma/client
Next, run the Prisma init
command with an optional --datasource-provider
parameter to set the type of database.
By default, the init command creates a PostgreSQL database, however, you can use the --datasource-provider
parameter to change the database type.
npx prisma init --datasource-provider postgresql
The above command will create a prisma
folder and either create a new .env
file or update an existing one.
The newly-created packages/server/prisma/schema.prisma
file will contain the configurations to connect to the database and the models needed to generate the database tables.
With Prisma, we define all our schemas or models in the packages/server/prisma/schema.prisma
file.
Let’s create a User
model and provide it with all the required attributes:
packages/server/prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User{
@@map(name: "users")
id String @id @default(uuid())
name String @db.VarChar(255)
email String @unique
photo String? @default("default.png")
verified Boolean? @default(false)
password String
role RoleEnumType? @default(user)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
provider String?
}
enum RoleEnumType {
user
admin
}
Next, open the packages/server/.env
file and update the dummy DATABASE_URL
added by Prisma with the database credentials.
packages/server/.env
NODE_ENV=development
ORIGIN=http://127.0.0.1:3000
DATABASE_URL="postgresql://postgres:password123@localhost:6500/trpc_prisma?schema=public"
Database Migration with Prisma
Before we can run the database migration command, you need to have a running instance of a PostgreSQL database.
Follow the Project Setup tutorial to set up the tRPC project with PostgreSQL, Express, Docker, and Prisma before continuing with this article.
Now add the following scripts to the packages/server/package.json
file:
{
"scripts": {
"start": "ts-node-dev --respawn --transpile-only src/app.ts",
"db:migrate": "npx prisma migrate dev --name user-entity --create-only && yarn prisma generate",
"db:push": "npx prisma db push"
},
}
db:migrate
– this script will run the Prismamigrate
command to create a new migration file and also generate the TypeScript types based on the models defined in theschema.prisma
file.db:push
– this script will run the Prismadb
command to push the changes to the database and keep the database in sync with the Prisma schema.
Open the terminal and run the following commands to generate the migration file and push the schema to the database.
yarn db:migrate && yarn db:push
If you are familiar with pgAdmin, you can log in with the database credentials to see the table added by Prisma.
Creating Schemas with Zod
By default, tRPC has support for schema validation libraries like Yup, Superstruct, Zod, and myzod. Also, it gives you the freedom to use your custom validators.
Run this command to install Zod:
yarn add zod
Next, create a packages/server/src/schemas/user.schema.ts
file with the following schema definitions.
packages/server/src/schema/user.schema.ts
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>;
Creating Functions to Sign and Verify JWTs
yarn add jsonwebtoken && yarn add -D @types/jsonwebtoken
To learn more about JWT Authentication with React, check out React + Redux Toolkit: JWT Authentication and Authorization
To learn more about Refresh Tokens with React, check out React.js + Redux Toolkit: Refresh Tokens Authentication
Generating the JWT Private and Public Keys
Follow these steps to generate the private and public keys needed to sign and verify the JWT Tokens.
Alternatively, you can use the ones I provided in the packages/server/.env
file.
Step 1: Go to the Private and Public Keys Generation Site, and click the “Generate New Keys” button to create the private and public keys.
Step 2: Copy the private key and visit the Base64 Encoding website to encode it in Base64.
Step 3: Copy the base64 encoded string and add it to the packages/server/.env
file as ACCESS_TOKEN_PRIVATE_KEY
.
Step 4: Go back to the Private and Public Keys Generation Site and copy the corresponding public key before encoding it in base64.
Add it to the packages/server/.env
file as ACCESS_TOKEN_PUBLIC_KEY
.
Step 5: Repeat the above steps for the refresh token private and public keys.
In the end, the packages/server/.env
should look somewhat like this:
packages/server/.env
NODE_ENV=development
ORIGIN=http://127.0.0.1:3000
DATABASE_URL="postgresql://postgres:password123@localhost:6500/trpc_prisma?schema=public"
ACCESS_TOKEN_PRIVATE_KEY=LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT1FJQkFBSkFVTWhaQjNucFJ6OEdrc0tneVZJcEZHMkJqZldHdElMWGNLUVFGMHZGbVZvOVVFcDhyOEVmCnI5T204azVTaXgrSi8rbXc0d08xVUlGb25rQTJFWnl6THdJREFRQUJBa0FDcVViOWp3K1hVRVU0S244L2dweGwKMXVHd3VvandnMnJ6aEFRZnNGaFhIKzlyQ1NWTmxNaEk0UWh4bWt3bHI2Y0NhUnFMUGg0Vk5lN3hKRDloWU5XcApBaUVBbXJ4TENiQXJJWDIvYkFETU1JdXljWFZKMnAwUk91S3FQOVBVeTB6ZG0zc0NJUUNGcGs5VDJKQ3NUVEhWCjErMWFVbk9JOFE3eUdNK1VQVGt6a200emNHcE8zUUloQUloOEU3Z2M4ejVjVzQ5WmVNSk5SbjI3VmdTRnpKL2oKTlFhTnc4SDdML0duQWlCTS9lUFJEMzg0WXpnRVV1SGZHSVNLTFNSSS8xWUZ0Y2RRR0ZqM3RSam8yUUlnS2t6ZwpVWFkwWjJRR1dqblFTdzdJRThaSDZuTHcydFUrci9LR0NZRzVKN3M9Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t
ACCESS_TOKEN_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZzd0RRWUpLb1pJaHZjTkFRRUJCUUFEU2dBd1J3SkFVTWhaQjNucFJ6OEdrc0tneVZJcEZHMkJqZldHdElMWApjS1FRRjB2Rm1WbzlVRXA4cjhFZnI5T204azVTaXgrSi8rbXc0d08xVUlGb25rQTJFWnl6THdJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0t
REFRESH_TOKEN_PRIVATE_KEY=LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT1FJQkFBSkFZOHRTUEZXMTk3bWgwcitCWUdLVTA4OFRPcDkrT2FObVNWQ1lMMTFhb05ZeEY1TSs1d0NSCnNDTnAxVEdHNW5zb215NW9QRitLajFsOGhjbmtUSUU2SndJREFRQUJBa0FVN2dLc1ZzbVlVQjJKWnRMS2xVSmoKZmUycGdPUG5VTWJXSDRvYmZQZlIvWGNteTdONkQyVXVQcnJ0MkdQVUpnNVJ4SG5NbVFpaDJkNHUwY3pqRDhpcApBaUVBcDFNaUtvY1BEWDJDU0lGN3c5SzVGWHlqMjIzQXJQcVJoUzNtL1dkVzVlVUNJUUNZcmxyeXRJOFkvODIzCkQ1ZTFHVExnbDlTcXN1UWdvaGF4ZCtKaXludGZHd0lnQ2xlK0xlakpTbWt1cTNLdGhzNDR1SlpLdnA2TElXWWYKcHA3T3YyMHExdTBDSVFDSy9lYWpuZ1hLLzB3NXcwTWJSUVpRK1VkTDRqRFZHRm5LVTFYUEUzOStVd0lnSEdLWgpjcDd2K3VyeG5kU05GK25MVEpZRG9abkMrKytteXRMaCtSUmU4dVU9Ci0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t
REFRESH_TOKEN_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZzd0RRWUpLb1pJaHZjTkFRRUJCUUFEU2dBd1J3SkFZOHRTUEZXMTk3bWgwcitCWUdLVTA4OFRPcDkrT2FObQpTVkNZTDExYW9OWXhGNU0rNXdDUnNDTnAxVEdHNW5zb215NW9QRitLajFsOGhjbmtUSUU2SndJREFRQUIKLS0tLS1FTkQgUFVCTElDIEtFWS0tLS0t
Next, let’s add the variables to the packages/server/src/config/default.ts
file to help us provide the TypeScript types for the environment variables.
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;
redisCacheExpiresIn: number;
} = {
port: 8000,
accessTokenExpiresIn: 15,
refreshTokenExpiresIn: 60,
redisCacheExpiresIn: 60,
origin: 'http://localhost:3000',
dbUri: process.env.DATABASE_URL 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 the above configurations, let’s create a packages/server/src/utils/jwt.ts
file and add these two functions to sign and verify the JSON Web Tokens.
Signing the JWT Tokens
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',
});
};
Verifying the JWT Tokens
packages/server/src/utils/jwt.ts
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;
}
};
Creating the Database Services with Prisma
One of the best practices of RESTful API architecture is to separate the business and application logic.
In layman’s terms, you should create services to interact with the database. That means the controllers should not be allowed to communicate directly with the database.
The rule is to push most of the business logic to the models and services, leaving us with thinner controllers and larger models.
Now let’s create a packages/server/src/services/user.service.ts
file with the following services.
packages/server/src/services/user.service.ts
import { Prisma, User } from '@prisma/client';
import customConfig from '../config/default';
import redisClient from '../utils/connectRedis';
import { signJwt } from '../utils/jwt';
import { prisma } from '../utils/prisma';
export const createUser = async (input: Prisma.UserCreateInput) => {
return (await prisma.user.create({
data: input,
})) as User;
};
export const findUser = async (
where: Partial<Prisma.UserCreateInput>,
select?: Prisma.UserSelect
) => {
return (await prisma.user.findFirst({
where,
select,
})) as User;
};
export const findUniqueUser = async (
where: Prisma.UserWhereUniqueInput,
select?: Prisma.UserSelect
) => {
return (await prisma.user.findUnique({
where,
select,
})) as User;
};
export const updateUser = async (
where: Partial<Prisma.UserWhereUniqueInput>,
data: Prisma.UserUpdateInput,
select?: Prisma.UserSelect
) => {
return (await prisma.user.update({ where, data, select })) as User;
};
export const signTokens = async (user: Prisma.UserCreateInput) => {
// 1. Create Session
redisClient.set(`${user.id}`, JSON.stringify(user), {
EX: customConfig.redisCacheExpiresIn * 60,
});
// 2. Create Access and Refresh tokens
const access_token = signJwt({ sub: user.id }, 'accessTokenPrivateKey', {
expiresIn: `${customConfig.accessTokenExpiresIn}m`,
});
const refresh_token = signJwt({ sub: user.id }, 'refreshTokenPrivateKey', {
expiresIn: `${customConfig.refreshTokenExpiresIn}m`,
});
return { access_token, refresh_token };
};
Creating the Authentication Handlers
We are now ready to create the authentication handlers to:
- Create a new user
- Request a new access token
- Sign the user into the account
- Log the user out from the account
To begin, let’s define the cookie options. Create a packages/server/src/controllers/auth.controller.ts
file with the following imports and cookie options.
packages/server/src/controllers/auth.controller.ts
import { TRPCError } from '@trpc/server';
import bcrypt from 'bcryptjs';
import { CookieOptions } from 'express';
import { Context } from '../app';
import customConfig from '../config/default';
import { CreateUserInput, LoginUserInput } from '../schema/user.schema';
import {
createUser,
findUniqueUser,
findUser,
signTokens,
} 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 = {
...cookieOptions,
expires: new Date(Date.now() + customConfig.accessTokenExpiresIn * 60 * 1000),
};
const refreshTokenCookieOptions: CookieOptions = {
...cookieOptions,
expires: new Date(
Date.now() + customConfig.refreshTokenExpiresIn * 60 * 1000
),
};
How to Register a New User
packages/server/src/controllers/auth.controller.ts
// [...] Register User Handler
export const registerHandler = async ({
input,
}: {
input: CreateUserInput;
}) => {
try {
const hashedPassword = await bcrypt.hash(input.password, 12);
const user = await createUser({
email: input.email.toLowerCase(),
name: input.name,
password: hashedPassword,
photo: input.photo,
provider: 'local',
});
return {
status: 'success',
data: {
user,
},
};
} catch (err: any) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2002') {
throw new TRPCError({
code: 'CONFLICT',
message: 'Email already exists',
});
}
}
throw err;
}
};
Let’s evaluate the above code:
First, we hashed the plain-text password provided by the user and called the createUser()
service to add the user to the PostgreSQL database.
Also, since we added a unique constraint to the email column, Prisma will return an error with a “P2002” code indicating that the email already exists in the PostgreSQL database.
How to Sign in the User
Now that we are able to create a user, let’s define the tRPC handler to sign in the registered user.
packages/server/src/controllers/auth.controller.ts
// [...] Register User Handler
// [...] Login User Handler
export const loginHandler = async ({
input,
ctx,
}: {
input: LoginUserInput;
ctx: Context;
}) => {
try {
// Get the user from the collection
const user = await findUser({ email: input.email.toLowerCase() });
// Check if user exist and password is correct
if (!user || !(await bcrypt.compare(input.password, user.password))) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invalid email or password',
});
}
// Create the Access and refresh Tokens
const { access_token, refresh_token } = await signTokens(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;
}
};
Quite a lot happening in the above, let’s break it down:
- First, we evoked the
findUser()
service to check if that email exists in the PostgreSQL database. - Next, we validated the plain-text password with the hashed one in the database.
- Lastly, we generated the access and refresh tokens and sent them to the tRPC client as HTTPOnly cookies.
How to Refresh the Access Token
packages/server/src/controllers/auth.controller.ts
// [...] Register User Handler
// [...] Login User Handler
// [...] Refresh Access Token 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 findUniqueUser({ id: 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;
}
};
How to SignOut the User
packages/server/src/controllers/auth.controller.ts
// [...] Register User Handler
// [...] Login User Handler
// [...] Refresh Access Token Handler
// [...] Logout User 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 as string);
logout({ ctx });
return { status: 'success' };
} catch (err: any) {
throw err;
}
};
Complete Code for the Authentication Handlers
packages/server/src/controllers/auth.controller.ts
import { TRPCError } from '@trpc/server';
import bcrypt from 'bcryptjs';
import { CookieOptions } from 'express';
import { Context } from '../app';
import customConfig from '../config/default';
import { CreateUserInput, LoginUserInput } from '../schema/user.schema';
import {
createUser,
findUniqueUser,
findUser,
signTokens,
} 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 = {
...cookieOptions,
expires: new Date(Date.now() + customConfig.accessTokenExpiresIn * 60 * 1000),
};
const refreshTokenCookieOptions: CookieOptions = {
...cookieOptions,
expires: new Date(
Date.now() + customConfig.refreshTokenExpiresIn * 60 * 1000
),
};
// [...] Register User Handler
export const registerHandler = async ({
input,
}: {
input: CreateUserInput;
}) => {
try {
const hashedPassword = await bcrypt.hash(input.password, 12);
const user = await createUser({
email: input.email.toLowerCase(),
name: input.name,
password: hashedPassword,
photo: input.photo,
provider: 'local',
});
return {
status: 'success',
data: {
user,
},
};
} catch (err: any) {
if (err instanceof Prisma.PrismaClientKnownRequestError) {
if (err.code === 'P2002') {
throw new TRPCError({
code: 'CONFLICT',
message: 'Email already exists',
});
}
}
throw err;
}
};
// [...] Login User Handler
export const loginHandler = async ({
input,
ctx,
}: {
input: LoginUserInput;
ctx: Context;
}) => {
try {
// Get the user from the collection
const user = await findUser({ email: input.email.toLowerCase() });
// Check if user exist and password is correct
if (!user || !(await bcrypt.compare(input.password, user.password))) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Invalid email or password',
});
}
// Create the Access and refresh Tokens
const { access_token, refresh_token } = await signTokens(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 Access Token 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 findUniqueUser({ id: 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 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 as string);
logout({ ctx });
return { status: 'success' };
} catch (err: any) {
throw err;
}
};
Creating a User Handler
packages/server/src/controllers/user.controller.ts
import { TRPCError } from '@trpc/server';
import type { 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 Guard
packages/server/src/middleware/deserializeUser.ts
import { TRPCError } from '@trpc/server';
import { findUniqueUser } from '../services/user.service';
import { Request, Response } from 'express';
import redisClient from '../utils/connectRedis';
import { verifyJwt } from '../utils/jwt';
export const deserializeUser = async ({
req,
res,
}: {
req: Request;
res: Response;
}) => {
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 findUniqueUser({ id: JSON.parse(session).id });
if (!user) {
return notAuthenticated;
}
return {
req,
res,
user,
};
} catch (err: any) {
throw new TRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: err.message,
});
}
};
Connecting the Routers to the App
Before adding the tRPC endpoints, install the following dependencies:
yarn add cookie-parser && yarn add -D @types/cookie-parser morgan @types/morgan
cookie-parser
– for parsing cookies in the request headersmorgan
– for logging the tRPC requests
Now open the packages/server/src/app.ts
file and add the following tRPC endpoints.
In addition, add the cookie parser to the middleware stack to enable Express to parse the cookies.
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 { inferAsyncReturnType, initTRPC, TRPCError } from "@trpc/server";
import * as trpcExpress from "@trpc/server/adapters/express";
import redisClient from "./utils/connectRedis";
import customConfig from "./config/default";
import connectDB from "./utils/prisma";
import { deserializeUser } from "./middleware/deserializeUser";
import { createUserSchema, loginUserSchema } from "./schema/user.schema";
import {
loginHandler,
logoutHandler,
refreshAccessTokenHandler,
registerHandler,
} from "./controllers/auth.controller";
import { getMeHandler } from "./controllers/user.controller";
import cookieParser from "cookie-parser";
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 = await redisClient.get("tRPC");
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, "http://localhost:3000"],
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
Congratulations on reaching the end. In this article, you learned how to implement access and refresh tokens in a tRPC API using Node.js, Express, PostgreSQL, Prisma, Docker-compose, and Redis.
tRPC, Prisma, PostgreSQL, & Express Source Code
Check out the:
This project is working after a while because of refreshing problem. Can you correct it please?
Thank you for reaching out. I’m glad to assist you with the refreshing problem in your project.
To better understand the issue and provide an appropriate solution, could you please provide more details about the specific problem you are facing?
This would help me diagnose the issue more effectively and provide you with the necessary guidance. Thank you!