Add JWT Authentication in Bun API
Tags: bunjs, elysia, typescript, prisma
Published: 2023-06-11 19:21:42

In this blog post we are going to add authentication in Bun based  REST API.

## Bun
Bun is relatively new javascript runtime that is built on top of JavaScriptCore engine (used in apple safari) and Zig programming language. It has built in transpiler, bundler, test runner and npm-compatible package manager.

## Elysia
Elysia is a fully type-safe web framework built on top of Bun having familier syntax like express.

## Prisma
Prisma is a Nodejs and Typescript ORM that reduce the burden of writting pure SQL command to interact with database. You can use both SQL and NoSQL database with prisma.

In this post we are going to use Postgresql to store user data and we will use Prisma cli to initialize new postgresql database and apply schema migrations.

## Prerequisite
Install Bun (https://bun.sh/docs/installation)
Setup Postgresql (https://www.postgresql.org/download/)

Lets create new elysia project using bun command line

```sh
bun create elysia auth
```
Now open `auth` project in vscode

```sh
cd auth
code .
```



`src/index.ts`

```ts

import Elysia from "elysia";
import { auth } from "~modules/auth";
import { cookie } from "@elysiajs/cookie";
import { jwt } from "@elysiajs/jwt";
const app = new Elysia()
  .group("/api", (app) =>
    app
      .use(
        jwt({
          name: "jwt",
          secret: Bun.env.JWT_SECRET!,
        })
      )
      .use(cookie())
      .use(auth)
  )
  .listen(8080);
console.log(
  `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`
);

```

First we created an instance of `Elysia` then we added `jwt` and `cookie`  plugins provided by elysia. You can install both plugins using bun command.

```sh
bun add @elysiajs/cookie @elysiajs/jwt
```

`cookie` plugin adds support for using cookie in Elysia handler and `jwt` plugin adds support for using JWT in Elysia handler. Internally `@elysiajs/jwt` use `jose` (https://github.com/panva/jose).

We used grouping features of elysia which allows you to combine multiple prefixes into one.

Suppose that we have these routes having repeated prefix.

`/api/auth/signup`
`/api/auth/login`
`/api/auth/logout`

Instead we can group them with prefix `/api/`.

For `jwt` plugin you can  explicitly register the JWT function with a different name using `name` property.

You can access environment variable in Bun using `Bun.env`. Create a dot  file on top level `.env.local` and add `JWT_SECRET`.

`.env.local`

```
JWT_SECRET="itssecret"
```

Then you can use `Bun.env.JWT_SECRET` to access `JWT_SECRET` value available in env file. Because Bun is Node compatible so you can also use `process.env.JWT_SECRET`.

In TypeScript, the exclamation mark (!) is known as the non-null assertion operator. It is used to assert that a value is not null or undefined.

We have registered `auth` module using `app.use(auth)` so that we can keep our auth related handlers separate.

Next we are going to setup prisma.

Add Prisma CLI as a development dependency

```sh
bun add -d  prisma
```
Next, set up your Prisma project by creating your Prisma schema file with the following command:

```sh
bunx prisma init
```

`bunx` is similer to `npx` or `pnpx` the primary purpose of `bunx` is to facilitate the execution of packages that are listed in the `dependencies` or `devDependencies` section of a project's `package.json` file. Instead of manually installing these packages globally or locally, you can use `bunx` to run them directly.

Now create user schema inside `prisma/schema.prisma` file

```
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model User {
  id                String    @id @default(uuid())
  name              String
  username          String    @unique
  email             String    @unique
  salt              String
  hash              String
  summary           String?
  links             Json?
  location          Json?
  profileImage      String
  createdAt         DateTime  @default(now())
  updatedAt         DateTime  @updatedAt
}

```
Next we are going to apply migrations to create `user` table in our database.

```sh
bunx prisma db push
```
Migrations are changes to your database schema, such as creating tables, altering columns, or adding indexes.

Next we are going to add prisma client package to interact with database.

```sh
bun add @prisma/client
```

Inside `/src/libs/prisma.ts` create instance of prisma client and export it.

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


Inside `/src/modules/auth/index.ts` add auth related handlers

```ts
import { Elysia, t } from "elysia";
import { prisma } from "~libs/prisma";
import { comparePassword, hashPassword, md5hash } from "~utils/bcrypt";
import { isAuthenticated } from "~middlewares/auth";
export const auth = (app: Elysia) =>
  app.group("/auth", (app) =>
    app
      .post(
        "/signup",
        async ({ body, set }) => {
          const { email, name, password, username } = body;
          // validate duplicate email address
          const emailExists = await prisma.user.findUnique({
            where: {
              email,
            },
            select: {
              id: true,
            },
          });
          if (emailExists) {
            set.status = 400;
            return {
              success: false,
              data: null,
              message: "Email address already in use.",
            };
          }

          // validate duplicate username
          const usernameExists = await prisma.user.findUnique({
            where: {
              username,
            },
            select: {
              id: true,
            },
          });

          if (usernameExists) {
            set.status = 400;
            return {
              success: false,
              data: null,
              message: "Someone already taken this username.",
            };
          }

          // handle password
          const { hash, salt } = await hashPassword(password);
          const emailHash = md5hash(email);
          const profileImage = `https://www.gravatar.com/avatar/${emailHash}?d=identicon`;

          const newUser = await prisma.user.create({
            data: {
              name,
              email,
              hash,
              salt,
              username,
              profileImage,
            },
          });

          return {
            success: true,
            message: "Account created",
            data: {
              user: newUser,
            },
          };
        },
        {
          body: t.Object({
            name: t.String(),
            email: t.String(),
            username: t.String(),
            password: t.String(),
          }),
        }
      )
      .post(
        "/login",
        async ({ body, set, jwt, setCookie }) => {
          const { username, password } = body;
          // verify email/username
          const user = await prisma.user.findFirst({
            where: {
              OR: [
                {
                  email: username,
                },
                {
                  username,
                },
              ],
            },
            select: {
              id: true,
              hash: true,
              salt: true,
            },
          });

          if (!user) {
            set.status = 400;
            return {
              success: false,
              data: null,
              message: "Invalid credentials",
            };
          }

          // verify password
          const match = await comparePassword(password, user.salt, user.hash);
          if (!match) {
            set.status = 400;
            return {
              success: false,
              data: null,
              message: "Invalid credentials",
            };
          }

          // generate access 

          const accessToken = await jwt.sign({
            userId: user.id,
          });
         
          setCookie("access_token", accessToken, {
            maxAge: 15 * 60, // 15 minutes
            path: "/",
          });
         

          return {
            success: true,
            data: null,
            message: "Account login successfully",
          };
        },
        {
          body: t.Object({
            username: t.String(),
            password: t.String(),
          }),
        }
      )
      .use(isAuthenticated)
      // protected route
      .get("/me", ({ user }) => {
        return {
          success: true,
          message: "Fetch authenticated user details",
          data: {
            user,
          },
        };
      })
  );


```

Here we have grouped all handlers in `/auth` prefix.

`set` is used to set status code , headers or redirect for response.
Using `body` we can parse request body data in our case this will be JSON request body.

We have used Elysia Schema to add validation for request body. Schema is used to define the strict type for the Elysia handler. Like in Login route we defined the structure of schema that we are going to receive from client in the third parameter of `app.post()`. Here we have added schema validation for `body` but you can add  schema validation for query, params, header etc...

Now create `/src/utils/bcrypt.ts` and add following codes:

```ts
import { randomBytes, pbkdf2, createHash } from "node:crypto";
async function hashPassword(
  password: string
): Promise<{ hash: string; salt: string }> {
  const salt = randomBytes(16).toString("hex");
  return new Promise((resolve, reject) => {
    pbkdf2(password, salt, 1000, 64, "sha512", (error, derivedKey) => {
      if (error) {
        return reject(error);
      }
      return resolve({ hash: derivedKey.toString("hex"), salt });
    });
  });
}

async function comparePassword(
  password: string,
  salt: string,
  hash: string
): Promise<boolean> {
  return new Promise((resolve, reject) => {
    pbkdf2(password, salt, 1000, 64, "sha512", (error, derivedKey) => {
      if (error) {
        return reject(error);
      }
      return resolve(hash === derivedKey.toString("hex"));
    });
  });
}

function md5hash(text: string) {
  return createHash("md5").update(text).digest("hex");
}

export { hashPassword, comparePassword, md5hash };

```

We added utility function to hash plain password , compare password and generate md5 hash from strings using `node:crypto` package.


`/src/middlewares/auth.ts`

```ts
import { Elysia } from "elysia";
import { prisma } from "~libs";

export const isAuthenticated = (app: Elysia) =>
  app.derive(async ({ cookie, jwt, set }) => {
    if (!cookie!.access_token) {
      set.status = 401;
      return {
        success: false,
        message: "Unauthorized",
        data: null,
      };
    }
    const { userId } = await jwt.verify(cookie!.access_token);
    if (!userId) {
      set.status = 401;
      return {
        success: false,
        message: "Unauthorized",
        data: null,
      };
    }

    const user = await prisma.user.findUnique({
      where: {
        id: userId,
      },
    });
    if (!user) {
      set.status = 401;
      return {
        success: false,
        message: "Unauthorized",
        data: null,
      };
    }
    return {
      user,
    };
  });

```
derive allows you to customize Context based on existing Context. Here we have retured `user` from derive now `user` will available in handlers context.

You can configure `tsconfig.json` `paths` directory to resolve non-relative module names.

```ts

 "paths": {
      "~libs/*":["./src/libs/*"],
      "~modules/*":["./src/modules/*"],
      "~utils/*":["./src/utils/*"],
      "~middlewares/*":["./src/middlewares/*"]
    },     
```

Lets start the server 

```sh
bun run dev
```
```
🦊 Elysia is running at 0.0.0.0:8080
```