DEV Community

Harsh Mangalam
Harsh Mangalam

Posted on

Implement JWT Refresh Token Authentication with Elysia JS and Prisma: A Step-by-Step Guide

In this comprehensive guide, we'll walk you through the process of integrating JWT refresh token authentication into your application using Elysia JS and Prisma.

Authentication vs Authorization

Authentication is the process of verifying the identity of a user or system attempting to access a resource or service.

Authorization is the process of determining what actions or resources a user is permitted to access within a system or application after they have been successfully authenticated.

JWT

JSON Web Token (JWT) authentication is a stateless, token-based authentication mechanism used to securely transmit information between parties as a JSON object

  • Header: Contains metadata about the token, such as the type of token (JWT) and the signing algorithm (e.g., HMAC SHA256 or RSA).

  • Payload: Contains the claims, which are statements about an entity (typically, the user) and additional data. Claims can be of three types: registered, public, and private.

  • Signature: Ensures that the token hasn't been altered. It's created by taking the encoded header, the encoded payload, a secret, the algorithm specified in the header, and signing them.

Tech Stack

Bun - Bun is a javascript runtime just like Nodejs and Deno but with better performance and developer experience.

Elysia - Elysia is a web framework built on top of Bun just like Express is a web framework built on top of Nodejs.

Prisma - Prisma is an ORM and Database Toolkit provide smoother way to connect SQL and NoSQL database. Prisma provide easy to use API to interact with db.

PostgreSQL - PostgreSQL is the World's Most Advanced Open Source Relational Database.

Typescript - A javascript with type safety features.

Setup new elysia project

Step 1
Make sure Bun is already installed in your system. You can install bun using curl

curl https://bun.sh/install | bash
Enter fullscreen mode Exit fullscreen mode

Step 2
Create new elysia project using bun. elysia-prisma-jwt-auth is name of our project

bun create elysia elysia-prisma-jwt-auth

Enter fullscreen mode Exit fullscreen mode

Step 3
Go to the project directory

cd elysia-prisma-jwt-auth
Enter fullscreen mode Exit fullscreen mode

Step 4
Now you can open the project in vscode

code .
Enter fullscreen mode Exit fullscreen mode

Step 5
Start the elysia server

bun dev
Enter fullscreen mode Exit fullscreen mode

You can also follow Elsysia Quick start guide to setup project or if you want custom setup
https://elysiajs.com/quick-start.html

In the next process we will define our required routes

  • POST /api/auth/sign-up - Create new account
  • POST /api/auth/sign-in - Sign in to existing account
  • GET /api/auth/me - Fetch current user
  • POST /api/auth/logout - Logout current user
  • POST /api/auth/refresh - Create new pair of access & refresh token from existing refresh token

Create new file route.ts to keep all routes related code here
src/route.ts

import { Elysia } from "elysia";

export const authRoutes = new Elysia({ prefix: "/auth" })
  .post(
    "/sign-in",
    async (c) => {
      return {
        message: "Sig-in successfully",
      };
    },

  )
  .post(
    "/sign-up",
    async (c) => {
      return {
        message: "Account created successfully",
      };
    },
  )
  .post(
    "/refresh",
    async (c) => {
      return {
        message: "Access token generated successfully",
      };
    }
  )
  .post("/logout", async (c) => {
    return {
      message: "Logout successfully",
    };
  })
  .get("/me", (c) => {
    return {
      message: "Fetch current user",
    };
  });

Enter fullscreen mode Exit fullscreen mode

Elysia is using method chaining to synchronize type safety for later use. Without method chaining, Elysia can't ensure your type integrity.

src/index.ts

import { Elysia } from "elysia";
import { authRoutes } from "./route";

const app = new Elysia({ prefix: "/api" }).use(authRoutes).listen(3000);

console.log(
  `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`
);

Enter fullscreen mode Exit fullscreen mode

Now import the auth routes and pass in use() method. We have added the prefix /api so that all the routes will start with /api like sign-in will be now /api/auth/sign-in.

Setup Prisma

Step 1
Install prisma cli as dev dependencies. dev dependencies are only required in local development it does not included in production build and runtime

bun add -d prisma

Enter fullscreen mode Exit fullscreen mode

Step 2
Initialize prisma project

bunx prisma init
Enter fullscreen mode Exit fullscreen mode

Step 3
Add prisma schema that will map to database table. Here we are going to add User schema to store user information.

enum UserRole {
  User
  Admin
}

model User {
  id           String    @id @default(uuid())
  name         String    @db.VarChar(60)
  email        String    @unique
  password     String
  location     Json?
  isAdult      Boolean   @default(false)
  isOnline     Boolean?  @default(false)
  role         UserRole? @default(User)
  refreshToken String?
  createdAt    DateTime  @default(now())
  updatedAt    DateTime  @updatedAt
}

Enter fullscreen mode Exit fullscreen mode

createdAt field will be added when new user will be added. updatedAt field will be initially same as createdAt but will change once you will be update any field of this table. For id here we are using uuid this will generate unique id as a string.

We have also created an enum for user role their value can be User or Admin and will help to implement Authorization and role based authentication.

Step 4
Update .env file created by prisma init command and add DATABASE_URL value. We are using PostgrSQL hence the URI will be in the form of postgresql://username:password@host:port/db?schema=public

DATABASE_URL="postgresql://harshmangalam:123456@localhost:5432/meetup?schema=public"

Enter fullscreen mode Exit fullscreen mode

You can omit ?schema=public by default in postgres it is public schema.

Step 5
Sync up the prisma schema with the postgresql database

bunx prisma db push
Enter fullscreen mode Exit fullscreen mode

This command should not be used in production in production always run migration command instead of push command.

For better developer expericence you can put this command in package.json scripts

"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "bun run --watch src/index.ts",
    "prisma:push": "bunx prisma db push"
  },
Enter fullscreen mode Exit fullscreen mode

So that later you can use this short command instead of long prisma command.

bun prisma:push
Enter fullscreen mode Exit fullscreen mode

Step 6
Install @prisma/client to make interaction with prisma server. usually this step is not required because during Step 5 its automatically get installed

bun i @prisma/client

Enter fullscreen mode Exit fullscreen mode

Step 7
generate prisma schema types for autocomplete. This step is also not required usually because during Step 5 it automatically get generated and added types to node_modules

Step 8
Create new instance of prisma client so that we can reuse that instance to make interact with db.

lib/prisma.ts

import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient();

Enter fullscreen mode Exit fullscreen mode

Now our db setup is completed and ready to use the prisma instance in our route handlers.

Implement Sign-up

src/route.ts

import { loginBodySchema, signupBodySchema } from "./schema";
import { prisma } from "./lib/prisma";
import { reverseGeocodingAPI } from "./lib/geoapify";
import { jwt } from "@elysiajs/jwt";
import {
  ACCESS_TOKEN_EXP,
  JWT_NAME,
  REFRESH_TOKEN_EXP,
} from "./config/constant";
import { getExpTimestamp } from "./lib/util";

.post(
    "/sign-up",
    async ({ body }) => {
      // hash password
      const password = await Bun.password.hash(body.password, {
        algorithm: "bcrypt",
        cost: 10,
      });

      // fetch user location from lat & lon
      let location: any;
      if (body.location) {
        const [lat, lon] = body.location;
        location = await reverseGeocodingAPI(lat, lon);
      }
      const user = await prisma.user.create({
        data: {
          ...body,

          password,
          location,
        },
      });
      return {
        message: "Account created successfully",
        data: {
          user,
        },
      };
    },
    {
      body: signupBodySchema,
      error({ code, set, body }) {
        // handle duplicate email error throw by prisma
        // P2002 duplicate field erro code
        if ((code as unknown) === "P2002") {
          set.status = "Conflict";
          return {
            name: "Error",
            message: `The email address provided ${body.email} already exists`,
          };
        }
      },
    }
  )
Enter fullscreen mode Exit fullscreen mode

Client will make an api call to /api/auth/sign-up with the json body

{
    "name":"Harsh Mangalam",
    "email":"harshdev8218@gmail.com",
    "password":"12345678",
    "isAdult":true,
    "location":[25.5940947,85.1375645] // [lat,lon]
}

Enter fullscreen mode Exit fullscreen mode

Bun has built in methods to hash passowrd you do not need to install any third party libs like bcryptjs or argon.
You can read more about this here Hash a password with Bun

I have create a function reverseGeocodingAPI() that will accept lat and lon to return the location from geoapify services.

We can configure geoapify using following steps:-

Step 1
Collect API key from https://www.geoapify.com/

Step 2
Create a new file lib/geoapify.ts that will handle making api call to geoapify service and collect location response from there

async function reverseGeocodingAPI(lat: number, lon: number) {
  const resp = await fetch(
    `https://api.geoapify.com/v1/geocode/reverse?lat=${lat}&lon=${lon}&apiKey=${Bun.env.GEOAPIFY_API_KEY}`
  );
  const jsonResp = await resp.json();
  const data = jsonResp?.features[0]?.properties;
  return data;
}

export { reverseGeocodingAPI };

Enter fullscreen mode Exit fullscreen mode

Again we do not need to install any third party libs for making api request like node-fetch, axios etc... because Bun support web standared and fetch is generally available to make api request built into the platform.

Next we will create schema for the body by default elysia use Typebox to provide type safety of request params, body, etc...

src/schema.ts

import { t } from "elysia";

const signupBodySchema = t.Object({
  name: t.String({ maxLength: 60, minLength: 1 }),
  email: t.String({ format: "email" }),
  password: t.String({ minLength: 8 }),
  location: t.Optional(t.Tuple([t.Number(), t.Number()])),
  isAdult: t.Boolean(),
});

export { signupBodySchema };
Enter fullscreen mode Exit fullscreen mode

Also we are handling errors for duplicate email because prisma throw error for duplicate fields with code P2002 in that case we can return Conflict status code with 409.

Implement Log-in

src/route.ts

.post(
    "/sign-in",
    async ({ body, jwt, cookie: { accessToken, refreshToken }, set }) => {
      // match user email
      const user = await prisma.user.findUnique({
        where: { email: body.email },
        select: {
          id: true,
          email: true,
          password: true,
        },
      });

      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"
        );
      }

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

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

      // set user profile as online
      const updatedUser = await prisma.user.update({
        where: {
          id: user.id,
        },
        data: {
          isOnline: true,
          refreshToken: refreshJWTToken,
        },
      });

      return {
        message: "Sig-in successfully",
        data: {
          user: updatedUser,
          accessToekn: accessJWTToken,
          refreshToken: refreshJWTToken,
        },
      };
    },
    {
      body: loginBodySchema,
    }
  )

Enter fullscreen mode Exit fullscreen mode

Client will make an api call to /api/auth/log-in and will send json body

{
    "email":"user5@gmail.com",
    "password":"12345678"
}

Enter fullscreen mode Exit fullscreen mode

We will verify the email and password from db. Next we will generate two tokens one for Access token and another for Refresh token.
We will send both tokens in response cookies so that the further api call to protected route will have those tokens and will store refresh token in db for further use to generate access token.

Again we do not need to add any third party libs for cookies handling Elysia provides all methods to handle cookies.

We will need to add jwt plugin to handle jwt token generation and verification

bun add @elysiajs/jwt
Enter fullscreen mode Exit fullscreen mode

You can read more about jwt plugins here https://elysiajs.com/plugins/jwt.html

Here also we have added login body schema we can add those schema in src/schema.ts file

...

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

export { loginBodySchema, signupBodySchema };

Enter fullscreen mode Exit fullscreen mode

Next we will create new auth plugin that will take care of velidate and verify jwt token when any request will received.

API Request ------->  Auth Plugin --------> API Handler

Enter fullscreen mode Exit fullscreen mode

src/plugin.ts

import jwt from "@elysiajs/jwt";
import Elysia from "elysia";
import { JWT_NAME } from "./config/constant";
import { prisma } from "./lib/prisma";
import { User } from "@prisma/client";

const authPlugin = (app: Elysia) =>
  app
    .use(
      jwt({
        name: JWT_NAME,
        secret: Bun.env.JWT_SECRET!,
      })
    )
    .derive(async ({ jwt, cookie: { accessToken }, 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;
      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");
      }

      return {
        user,
      };
    });

export { authPlugin };


Enter fullscreen mode Exit fullscreen mode

Here we have utilized the jwt plugin to verify jwt token received from request cookies. During login we have added userId in jwt sub and here we have just got the userId and fetch user info from db and added to derive so that available in next request handler.

Here we have raised two error status code

  • 401 Unauthorized that can be raise in case of access token is not available
  • 403 Forbidden in case of access token is incorrect.

Lets utilize auth plugin in our protected route like /api/auth/logout/ and /api/auth/me.

Create new route to fetch current user
/src/route.ts

import { authPlugin } from "./plugin";

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

Enter fullscreen mode Exit fullscreen mode

We are receiving user in context that are added from derive in auth plugin.

Lets add new route to logout user

/src/route.ts

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

    // remove refresh token from db & set user online status to offline
    await prisma.user.update({
      where: {
        id: user.id,
      },
      data: {
        isOnline: false,
        refreshToken: null,
      },
    });
    return {
      message: "Logout successfully",
    };
  })
Enter fullscreen mode Exit fullscreen mode

After logout we are just removing all the cookies and setting user status to offline.

Create access token from refresh token

/src/route.ts

  .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");
      }
      // create new access token
      const accessJWTToken = await jwt.sign({
        sub: user.id,
        exp: getExpTimestamp(ACCESS_TOKEN_EXP),
      });
      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),
      });
      refreshToken.set({
        value: refreshJWTToken,
        httpOnly: true,
        maxAge: REFRESH_TOKEN_EXP,
        path: "/",
      });

      // set refresh token in db
      await prisma.user.update({
        where: {
          id: user.id,
        },
        data: {
          refreshToken: refreshJWTToken,
        },
      });

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

Enter fullscreen mode Exit fullscreen mode

Here we are re creating the access token and refresh token from existing refresh token and setting in cookies also we are updating available refresh token in db.

I have added all constants in src/config/constant.ts

const ACCESS_TOKEN_EXP = 5 * 60; // 5 minutes
const REFRESH_TOKEN_EXP = 7 * 86400; // 7 days
const JWT_NAME = "jwt";
export { ACCESS_TOKEN_EXP, REFRESH_TOKEN_EXP, JWT_NAME };

Enter fullscreen mode Exit fullscreen mode

I have created one utility function related to date that will return timestamps from seconds.

src/lib/util.ts

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

  return Math.floor(expirationTimeMillis / 1000);
}

export { getExpTimestamp };


Enter fullscreen mode Exit fullscreen mode

All the codebase is open source you can access and contribute to repo
https://github.com/harshmangalam/elysia-prisma-jwt-auth

Top comments (8)

Collapse
 
ilmi profile image
Achmad Ilmi Al Akbar

Hello there! I'm just starting to learn how to use Elysia with "stateless" JWT authentication. As a beginner, I have a quick question: why isn't there an exp field when I check the JWT value? I've read the post and followed the tutorial, but I still don't see the exp field.

Image description

Collapse
 
harshmangalam profile image
Harsh Mangalam

You will get all the keys whatever you will add during token creation.
Here I have only added sub field hence i am getting sub from jwt decode.

{
  sub: "33a52096-a804-47c8-8cc9-993597ffc164",
}

Enter fullscreen mode Exit fullscreen mode
Collapse
 
harshmangalam profile image
Harsh Mangalam

Have you decoded the payload from jwt.io/ ?

Collapse
 
ilmi profile image
Achmad Ilmi Al Akbar

fixed it the only issue was the jwt library itself it doesnt allow us to assign number as its expiry
as mentioned in: github.com/elysiajs/elysia-jwt/iss...

but we can set it through this way instead

Image description

here's the result
Image description

Anyway thanks for the tutorial! helps me alot to get used with this Elysiajs thing :)

Collapse
 
risavkarna profile image
Risav

Why are you storing the refreshToken in your DB? You don't seem to be using it for anything.

Collapse
 
harshmangalam profile image
Harsh Mangalam • Edited
  • Storing refresh tokens in a database helps to maintain user sessions and provide a secure authentication mechanism.
  • Storing refresh tokens in a db ensure that they persist across server restarts or crashes.
  • Support multiple devices per user, storing refresh tokens in a database allows you to manage and track refresh tokens for each device separately.

Yes i have not used the refresh token from db because i was trying to keep it simple. But better to use redis for fast and efficient query across multiple servers.

Collapse
 
hoanggbao profile image
hoanggbao00

wow, so many thanks for your tutorial.

Collapse
 
maprangsoft profile image
Maprangsoft

thank you.