JWT Authentication Elysia.js with Redis #3

M Sadewa Wicaksana
7 min readSep 27, 2024

--

Today, I’ll be sharing details about a mini-project i worked on that implements authentication while incorporating security features like single session enforcement. Single session means that a user can only have one active login at a time, which helps prevent race conditions within the system. There’s a lot of information I’d like to discuss, but I’ll focus on making it straightforward and easy to understand. JWT (JSON Web Token) is like a digital ID card used for secure communication between a client (like a browser or app) and a server.

I’ll break this information into several parts:

a. Single Session

b. Redis

c. In Actions with JWT Authentication

Single Session

Single session is a security feature that ensures a user can only have one active login at a time. If the same account tries to log in from a different device or browser, the previous session will automatically be logged out. This helps prevent multiple people from using the same account simultaneously and protects against race conditions or misuse.

Illustration Single Session

In this sections, we will build function single session with utilize redis as our temporary store for refresh token. The image below will illustrate how scenario will happen when user interaction with our token for example.

scenario our single session systems

Redis

Redis is an in-memory database that stores data in a way that’s very fast to access. Think of it like a super-speedy storage box that can quickly keep and retrieve information, making it perfect for tasks like caching, managing real-time data, or storing temporary information such as user sessions. Because everything is stored in memory (RAM), it responds in milliseconds, making it much faster than traditional databases. People often use Redis to handle quick lookups, keep track of active users, or manage things like chat messages and queues.

To install redis on mac-os follow step by step with this link https://redis.io/docs/latest/operate/oss_and_stack/install/install-redis/install-redis-on-mac-os/

In Action JWT

Before we start, first of all install the redis and jwt library using bun command

bun add @elysiajs/jwt
bun add redis

Create folder /lib which will contain file about behaviour our third-party. In that folder create file name RedisClient.ts. That file will be our function to setup connection with our localhost redis.

#RedisClient.ts
import { createClient, RedisClientType } from "redis";

export const RedisClientConfig: RedisClientType = createClient({
url: "redis://localhost:6379",
});

export async function initializeRedisClient(): Promise<RedisClientType> {
RedisClientConfig.on("error", (err: any) =>
console.log("Redis Client Error", err)
);
RedisClientConfig.on("connect", () => console.log("Redis Client Connected"));

await RedisClientConfig.connect();

return RedisClientConfig;
}

Call function initializeRedisClient in our main index.ts.

const app = new Elysia();

//cors
app.use(
cors({
preflight: true,
origin: "*",
methods: ["GET", "POST", "PUT", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
})
);

//server timing
app.use(
serverTiming({
trace: {
request: true,
mapResponse: true,
total: true,
},
})
);

//swagger
app.use(
swagger({
documentation: {
info: {
title: "API Boilerplate Elysia.js",
version: "0.0.1-rc",
},
tags: [
{
name: "User",
description: "Endpoints related to user",
},
{
name: "Default",
description: "Basic default templates",
},
],
},
path: "/",
})
);

// redis client config
initializeRedisClient();

Create function inside folder utils to get expiration timestamp

function getExpTimestamp(seconds: number) {
const currentTimeMillis = Date.now();
const secondsIntoMillis = seconds * 1000;
const expirationTimeMillis = currentTimeMillis + secondsIntoMillis;

return Math.floor(expirationTimeMillis / 1000);
}

export { getExpTimestamp };

API Sign In

Create Sign-in API with some steps below

  1. Create controller to handle if email user is exist in our database
  2. Verify the password is match
  3. Create access token jwt and set it into our cookies. There are two token that we will use that are accessToken and refreshToken
  4. Store the refresh_token into our redis client.
# UserController
loginUser: async ({ body }: { body: UserLoginProps }) => {
try {
return await prisma.user.findUnique({
where: { email: body.email },
select: {
id: true,
email: true,
password: true,
},
});
} catch (error) {
console.log("🚀 ~ loginUser: ~ error:", error);
return {
data: [],
message: strings.response.failed,
};
}
},
# RouteUser.ts
export const RouteUsers = (app: Elysia) =>
app
.use(
jwt({
name: JWT_NAME,
secret: Bun.env.JWT_SECRET!,
})
)
.group("/user", (user) => {
user.post(
"/",
async ({ body }) =>
UserController.addUser({
body: body,
}),
{
body: UserCreateModels,
tags: ["User"],
type: "multipart/form-data",
}
);

user.post(
"/sign-in",
async ({ body, jwt, cookie: { accessToken, refreshToken }, set }) => {
const user = (await UserController.loginUser({
body: body,
})) as UserResponseByIdProps;
if (!user) {
set.status = "Bad Request";
throw new Error(
"The email address or password you entered is incorrect"
);
}

// match password
const matchPassword = await Bun.password.verify(
body.password,
user.password,
"bcrypt"
);
if (!matchPassword) {
set.status = "Bad Request";
throw new Error(
"The email address or password you entered is incorrect"
);
}

const datetime = Math.floor(Date.now() / 1000);

// create access token
const accessJWTToken = await jwt.sign({
sub: user.id,
exp: getExpTimestamp(ACCESS_TOKEN_EXP),
iat: datetime,
});

accessToken.set({
value: accessJWTToken,
httpOnly: true,
maxAge: ACCESS_TOKEN_EXP,
sameSite: "lax",
path: "/",
secure: true,
});

// create refresh token
const refreshJWTToken = await jwt.sign({
sub: user.id,
exp: getExpTimestamp(REFRESH_TOKEN_EXP),
iat: datetime,
});
refreshToken.set({
value: refreshJWTToken,
httpOnly: true,
maxAge: REFRESH_TOKEN_EXP,
path: "/",
sameSite: "lax",
secure: true,
});

await RedisClientConfig.hSet(
user.id,
"refresh_token",
refreshJWTToken
);

return {
message: "Sigin successfully",
data: {
user: user,
accessToken: accessJWTToken,
refreshToken: refreshJWTToken,
},
};
},
{
body: UserLoginModels,
}
);

return user;
});
# UserModel.ts
import { t } from "elysia";

export const UserCreateModels = t.Object({
name: t.String({ maxLength: 250, default: "" }),
email: t.String({ format: "email", default: "sampel@tes.id" }),
password: t.String({ default: "12345", minLength: 8 }),
file_avatar: t.File({
type: "image/png",
}),
});

export const UserLoginModels = t.Object({
email: t.String({ format: "email" }),
password: t.String({ minLength: 8 }),
});

export interface UserCreateProps {
name: string;
email: string;
password: string;
file_avatar: File;
}

export interface UserLoginProps {
email: string;
password: string;
}

export interface UserResponseByIdProps {
id: string;
email: string;
password: string;
}

After implement login, we will create a middleware to check our token JWT before request finish on the route. We can call it AuthPlugin.ts and create it inside folder lib. In this middleware there are some conditional error like:

  1. Availability accessToken
  2. Verification JWT
  3. Ensure user in our db exist
  4. Validate the refresh token user use is match with the redis store to make our apps single session.
const prisma = new PrismaClient();

const AuthPlugin = (app: Elysia) =>
app
.use(
jwt({
name: JWT_NAME,
secret: Bun.env.JWT_SECRET!,
})
)
.derive(async ({ jwt, cookie: { accessToken, refreshToken }, set }) => {
if (!accessToken.value) {
// handle error for access token is not available
set.status = "Unauthorized";
throw new Error("Access token is missing");
}
const jwtPayload = await jwt.verify(accessToken.value);
if (!jwtPayload) {
// handle error for access token is tempted or incorrect
set.status = "Forbidden";
throw new Error("Access token is invalid");
}

const userId = jwtPayload.sub as string;

const user = await prisma.user.findUnique({
where: {
id: userId,
},
});

if (!user) {
// handle error for user not found from the provided access token
set.status = "Forbidden";
throw new Error("Access token is invalid");
}

// implement single session based on redis to make sure that token is same with the new
const redisToken = await RedisClientConfig.hGetAll(userId);

if (redisToken.refresh_token !== refreshToken.value) {
set.status = "Unauthorized";
throw new Error("Refresh token is invalid");
}

return {
user,
};
});

export { AuthPlugin };

API Me

user.use(AuthPlugin).get("/me", ({ user }) => {
return {
message: "Fetch current user",
data: {
user,
},
};
});

API User

#RouteUser.ts
user.use(AuthPlugin).get("/", () => UserController.getUser(), {
tags: ["User"],
});
#UserController.ts
getUser: async () => {
try {
const user = await prisma.user.findMany({});
return {
data: user,
message: strings.response.success,
};
} catch (error) {
console.log("🚀 ~ getUser: ~ error:", error);
return {
data: [],
message: strings.response.failed,
};
}
}

API Refresh Token

In refresh token there are some conditional error we will implement, like:

  1. Validity refreshToken value
  2. Verify JWT signature
  3. Verify user which used the JWT is exist

When conditional errors doesn’t reach, system will create new token and update into cookie and redis client.

user
.use(AuthPlugin)
.post(
"/refresh",
async ({ cookie: { accessToken, refreshToken }, jwt, set }) => {
if (!refreshToken.value) {
// handle error for refresh token is not available
set.status = "Unauthorized";
throw new Error("Refresh token is missing");
}
// get refresh token from cookie
const jwtPayload = await jwt.verify(refreshToken.value);
if (!jwtPayload) {
// handle error for refresh token is tempted or incorrect
set.status = "Forbidden";
throw new Error("Refresh token is invalid");
}

// get user from refresh token
const userId = jwtPayload.sub;

// verify user exists or not
const user = await prisma.user.findUnique({
where: {
id: userId,
},
});

if (!user) {
// handle error for user not found from the provided refresh token
set.status = "Forbidden";
throw new Error("Refresh token is invalid");
}

const datetime = Math.floor(Date.now() / 1000);

// create new access token
const accessJWTToken = await jwt.sign({
sub: user.id,
exp: getExpTimestamp(ACCESS_TOKEN_EXP),
iat: datetime,
});
accessToken.set({
value: accessJWTToken,
httpOnly: true,
maxAge: ACCESS_TOKEN_EXP,
path: "/",
});

// create new refresh token
const refreshJWTToken = await jwt.sign({
sub: user.id,
exp: getExpTimestamp(REFRESH_TOKEN_EXP),
iat: datetime,
});
refreshToken.set({
value: refreshJWTToken,
httpOnly: true,
maxAge: REFRESH_TOKEN_EXP,
path: "/",
});

// set refresh token in db
await RedisClientConfig.hSet(user.id, "refresh_token", refreshJWTToken);

return {
message: "Access token generated successfully",
data: {
accessToken: accessJWTToken,
refreshToken: refreshJWTToken,
},
};
}
);

API Logout

user
.use(AuthPlugin)
.post(
"/logout",
async ({ cookie: { accessToken, refreshToken }, user }) => {
// remove refresh token and access token from cookies
accessToken.remove();
refreshToken.remove();

return {
message: "Logout successfully",
};
}
);

--

--

M Sadewa Wicaksana
M Sadewa Wicaksana

Written by M Sadewa Wicaksana

Artificial Intelligence and Fullstack Engineering Enthusiast and Still Learning

No responses yet