Node Auth

In this article I want to describe a simple method to implement JWT (JASON WEB TOKEN) authentication with Node, Express and Typescript.

A demo with the final result can be tested here meanwhile the source code can be seen at this link.

Other packages used to build this app are Prisma as ORM to interact with a PostgreSQL database hosted on Supabase, Pug as template engine and TailwindCSS for styling views.

Overview

How JWT authentication works

JASON Web Token is a valid alternative to session authentication and one main difference is related to the possibility to be State-less. A system designed with this form of authentication doesn't require to access a database to gather information about the user on every request and in some cases this can allow better performances.

User informations can be extracted directly from a token which is encrypted and decrypted using a secret by the app.

In the case presented the system is not properly stateless, because on every request we access the db to ask for updated user info.

Like every system JWT is not perfect and may not be the best choice for a project, but in many cases is the right path to secure an application and most APIs on the web are authenticated using this method.

Project setup

Using Typescript and TailwindCSS caused this first step to be longer. The first thing to do after we add the package.json to the project is to install all the required dependencies.

package.json

...
  "dependencies": {
    "@prisma/client": "5.8.0",
    "bcrypt": "^5.1.1",
    "cookie-parser": "^1.4.6",
    "dotenv": "^16.3.1",
    "express": "^4.18.2",
    "jsonwebtoken": "^9.0.2",
    "pug": "^3.0.2",
    "resend": "^3.0.0"
  },
  "devDependencies": {
    "@types/bcrypt": "^5.0.2",
    "@types/cookie-parser": "^1.4.6",
    "@types/express": "^4.17.21",
    "@types/jsonwebtoken": "^9.0.5",
    "@types/node": "^20.11.0",
    "autoprefixer": "^10.4.16",
    "nodemon": "^3.0.2",
    "postcss": "^8.4.33",
    "postcss-cli": "^11.0.0",
    "prisma": "5.8.0",
    "tailwindcss": "^3.4.1",
    "ts-node": "^10.9.2",
    "typescript": "^5.3.3"
  }
...

Next thing to do is to add some configuration files for typescript and tailwindcss.

tsconfig.json

{
  "ts-node": {
    "files": true,
  },
  "files": ["./src/index.ts", "./src/types/index.d.ts"],
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
  },
}

tailwind.config.js

module.exports = {
  content: ["./src/views/*.pug"],
  theme: {
    extend: {},
  },
  plugins: [],
};

postcss.config.js

module.exports = {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
};

This configurations and the related packages can be avoided because they are not strictly required for JWT implementation.

Here are some script to add to run the project and another configuration file for nodemon, a great tool to speed up development.

nodemon.json

{
  "watch": ["src"],
  "ext": ".ts,.pug",
  "exec": "tsc ./src/public/js/index.ts --outFile ./src/public/js/index.js && postcss ./src/public/styles/tailwind.css -o ./src/public/styles/global.css && ts-node ./src/index.ts"
}

package.json

...
  "scripts": {
    "start": "ts-node src/index.ts",
    "dev": "nodemon src/index.ts",
    "build": "yarn install && tsc ./src/public/js/index.ts --outFile ./src/public/js/index.js && postcss ./src/public/styles/tailwind.css -o ./src/public/styles/global.css"
  },
...

Prisma schema

Prisma is used to model user entity and access the PostgreSQL database and one important part of this project concern the definition of the User schema.

prisma/schema.prisma

model User {
  id                   Int       @id @default(autoincrement())
  email                String    @unique
  name                 String?
  password             String?
  passwordChangedAt    DateTime  @default(now())
  passwordResetToken   String?
  passwordResetExpires DateTime?
}

Once the schema is defined and model are generated it is possible to use Prisma Client to access and modify data.

Authentication process

Users will be authenticated using email and password that encrypted using bcrypt and then stored.

src/controllers/auth.ts

...
import jwt from "jsonwebtoken";
...

...
export const signin: RequestHandler = async (req, res, next) => {
  try {
    const { email, password } = req.body;

    if (!email || !password) {
      return next(new AppError("Please provide email and password!", 400));
    }

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

    if (!user || !(await bcrypt.compare(password, user?.password!))) {
      return next(
        new AppError(
          "Invalid email or password. Please try again with the correct credentials.",
          401,
        ),
      );
    }

    const token = jwt.sign(
      {
        id: user.id,
      },
      process.env.JWT_SECRET!,
      { expiresIn: process.env.JWT_EXPIRES_IN },
    );

    res.cookie("jwt", token, {
      expires: new Date(
        Date.now() +
          parseInt(process.env.JWT_COOKIE_EXPIRES_IN!) * 24 * 60 * 60 * 1000,
      ),
      httpOnly: true,
    });

    user.password = null;

    res.status(200).json({
      status: "success",
      token,
      data: {
        user,
      },
    });
  } catch (error) {
    return next(error);
  }
};

export const signup: RequestHandler = async (req, res, next) => {
  const { email, name, password } = req.body;
  try {
    const hash = await bcrypt.hash(password, 10);
    const user = await prisma.user.create({
      data: {
        name,
        email,
        password: hash,
      },
    });

    const token = jwt.sign(
      {
        id: user.id,
      },
      process.env.JWT_SECRET!,
      { expiresIn: process.env.JWT_EXPIRES_IN },
    );

    res.cookie("jwt", token, {
      expires: new Date(
        Date.now() +
          parseInt(process.env.JWT_COOKIE_EXPIRES_IN!) * 24 * 60 * 60 * 1000,
      ),
      httpOnly: true,
    });

    user.password = null;

    res.status(200).json({
      status: "success",
      token,
      data: {
        user,
      },
    });
  } catch (error) {
    return next(error);
  }
};
...

The package jasonwebtoken is used to generate a token that will be send to the client will be used to access protected routes.

Middlewares are used to check if a user is authenticated.

src/middlewares/auth.ts

// For views protection
export const isLoggedIn = async (
  req: Request,
  res: Response,
  next: NextFunction,
) => {
  const token = req.cookies["jwt"];

  if (token) {
    try {
      const decoded: any = jwt.verify(token, process.env.JWT_SECRET!);
      const user = await prisma.user.findUnique({
        where: {
          id: decoded.id,
        },
        select: {
          email: true,
          name: true,
          id: true,
        },
      });

      if (!user) {
        res.redirect("/signin");
      }

      res.locals.user = user;
      return next();
    } catch (error) {
      console.log(error);
      res.redirect("/signin");
    }
  }

  res.redirect("/signin");
};

// For API endpoints protection
export const protect = async (
  req: Request,
  res: Response,
  next: NextFunction,
) => {
  try {
    let token;
    if (
      req.headers.authorization &&
      req.headers.authorization.startsWith("Bearer")
    ) {
      token = req.headers.authorization.split(" ")[1];
    } else if (req.cookies["jwt"]) {
      token = req.cookies["jwt"];
    }

    if (!token) {
      return next(
        new AppError(
          "You are not logged in! Pleasse log in to get access.",
          401,
        ),
      );
    }

    const decoded: any = jwt.verify(token, process.env.JWT_SECRET!);
    const user = await prisma.user.findUnique({
      where: {
        id: decoded.id,
      },
      select: {
        name: true,
        email: true,
        id: true,
      },
    });

    if (!user) {
      return next(
        new AppError(
          "The user belonging to this token does no longer exist",
          401,
        ),
      );
    }

    req.user = {
      email: user.email,
      name: user.name as string,
      id: user.id.toString(),
    };
    res.locals.user = user;

    next();
  } catch (error) {
    return next(error);
  }
};

Conclusion

This project can be used as starting point to build the authentication for an API or a full stack app. It is not perfect but is solid base, and I think it can be useful to many developers who don't know where to start. The entire source code can be found on github and I will improve it in the future.

Useful resources