Implement JWT Refresh Token Authentication with Elysia JS and Prisma: A Step-by-Step Guide
Tags: prisma, typescript, webdev, bunjs
Published: 2024-06-13 16:39:03

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`
```sh
curl https://bun.sh/install | bash
```

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

```sh
bun create elysia elysia-prisma-jwt-auth

```

`Step 3`
Go to the project directory
```sh
cd elysia-prisma-jwt-auth
```

`Step 4`
Now you can open the project in vscode 
```sh
code .
```

`Step 5`
Start the elysia server
```sh
bun dev
```

You can also follow Elsysia Quick start guide to setup project or if you want custom setup
[https://elysiajs.com/quick-start.html](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`

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

```

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

```

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

```sh
bun add -d prisma

```

`Step 2`
Initialize prisma project
```sh
bunx prisma init
```

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

```prisma
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
}

```
`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`
```sh
DATABASE_URL="postgresql://harshmangalam:123456@localhost:5432/meetup?schema=public"

```
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
```

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

```json
"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "dev": "bun run --watch src/index.ts",
    "prisma:push": "bunx prisma db push"
  },
```
So that later you can use this short command instead of long prisma command.

```sh
bun prisma:push
```

`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

```sh
bun i @prisma/client

```
`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`

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

```

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

## Implement Sign-up 

`src/route.ts`
```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`,
          };
        }
      },
    }
  )
```

Client will make an api call to `/api/auth/sign-up` with the json body 
```json
{
	"name":"Harsh Mangalam",
	"email":"harshdev8218@gmail.com",
	"password":"12345678",
	"isAdult":true,
	"location":[25.5940947,85.1375645] // [lat,lon]
}

```
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](https://bun.sh/guides/util/hash-a-password)

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/](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

```ts
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 };

```

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`

```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 };
```

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`

```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,
    }
  )

```

Client will make an api call to `/api/auth/log-in` and will send json body 
```json
{
	"email":"user5@gmail.com",
	"password":"12345678"
}

```

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

```sh
bun add @elysiajs/jwt
```
You can read more about jwt plugins here [https://elysiajs.com/plugins/jwt.html](https://elysiajs.com/plugins/jwt.html)


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

```ts
...

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

export { loginBodySchema, signupBodySchema };

```

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

```

`src/plugin.ts`

```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 };


```

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`

```ts
import { authPlugin } from "./plugin";

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

```

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


Lets add new route to logout user

`/src/route.ts`
```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",
    };
  })
```


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



Create access token from refresh token 

`/src/route.ts`

```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,
        },
      };
    }
  )

```

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`

```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 };

```

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

`src/lib/util.ts`
```ts
function getExpTimestamp(seconds: number) {
  const currentTimeMillis = Date.now();
  const secondsIntoMillis = seconds * 1000;
  const expirationTimeMillis = currentTimeMillis + secondsIntoMillis;

  return Math.floor(expirationTimeMillis / 1000);
}

export { getExpTimestamp };


```

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