DEV Community

Cover image for Authentication and authorization in Node.js applications
Dan for Cerbos

Posted on

Authentication and authorization in Node.js applications

Thanks to our friendly Cerbos community member—who wishes to remain anonymous—for this tutorial. For more great community content and discussions, join our Slack today!

In this article, we will discuss authentication and authorization, and how to implement both JWT authentication and Cerbos authorization in a Node.js application.

Understanding authentication

Authentication is the process of verifying the identity of a user. In a Node.js application, we typically authenticate a user based on a set of credentials, such as a username and password, to gain access to the application.

Some common authentication methods are:

  • Username and password
  • Token-based authentication
  • Single Sign-On (SSO)

The authentication process works like this:

  • The user provides their credentials.
  • The server verifies the provided credentials.
  • If the credentials are valid, the server generates an authentication token or session ID for the user.
  • The newly generated token/session ID is sent back to the client and stored for future use.
  • To prove their authenticity, the user includes the token/session ID in each request.

Understanding authorization

Authorization is the process of giving access to users to do specific work/actions. It decides what an authenticated user is allowed to do within the application. After authentication, authorization makes sure that the user only does what they're allowed to do, following specific rules and permissions.

Some common authentication models are:

How does authorization work?

The authorization process works like this:

  • The authenticated user makes a request to the server.
  • The server verifies the user and gets their assigned roles and permissions.
  • The server evaluates if the user has permission for the requested actions.
  • If the user is authorized, the server allows the user to perform the requested actions.
  • Otherwise, it denies access and returns an appropriate error response.

What is RBAC?

Role Based Access Control, or RBAC, is an access control method that gives permissions to the user based on their assigned roles within our applications. In simple words, it controls what users can do within the application.

Instead of assigning permissions to individual users, RBAC groups users into roles, and then assigns permissions to those roles. This makes it easier to manage access control, as we only need to update permissions for a role, rather than for each individual user.

Key components of RBAC are:

  • Role: Defines a set of responsibilities, tasks, or functions within the application.
  • Permissions: Represents specific actions that users can perform.
  • Role Assignments: Users are assigned to one or more roles, and each role is associated with a set of permissions.
  • Access control policies: Dictate which roles can access particular resources and what actions they can take.

{User} -- is assigned to --> {Role} -- has --> {Permission} -- grants access to --> {Resource}

RBAC is powerful, but implementing and managing it yourself is not easy. The open-source Cerbos PDP (or Policy Decision Point), acts as a stand-alone service, streamlining access control by centralizing both policy decisions and audit log collection, and decoupling authorization logic from your application.

Cerbos can function effectively within diverse software environments, from monoliths to fully decentralized architectures. It’s stateless and can be deployed via a Kubernetes sidecar, in a Docker container, or even as a single binary on a virtual machine. Cerbos exposes a rich HTTP API, which makes it language-agnostic, and allows it to interact and scale seamlessly with other services.

Implementing authentication and authorization in Node.js

Now that we have an understanding of authentication and authorization, let’s explore how to implement them in our Node.js application. In this section, we’ll build a simple Node.js app and integrate authentication and authorization functionalities.

Prerequisites

Before starting the project, make sure you have:

  • Node.js and npm installed on your system.
  • Docker installed on your machine (for running the Cerbos container).
  • Postman or a similar tool for testing API endpoints.
BlogCTA - PDP

Set up a Node.js project

Create the project

To start with, we need to set up a Node.js project. Run the following command in the terminal:

npm init -y
Enter fullscreen mode Exit fullscreen mode

This will initialize a new Node.js project.

Install the dependencies

Next, install the required dependencies for our project:

npm install express jsonwebtoken dotenv bcryptjs nodemon
Enter fullscreen mode Exit fullscreen mode

This will install the following packages:

  • express: A popular web framework for Node.js.
  • jsonwebtoken: For generating and verifying JSON Web Tokens (JWT) for authentication.
  • dotenv: loads environment variables from a .env file.
  • bcryptjs: to hash the password.
  • nodemon: nodemon helps Node.js-based application restarts automatically when there is a change in the code.

Environment variables

Next, create a .env file to securely store our sensitive information, such as API credentials.

JWT_SECRET= "Your_Secret_Key_Here"
PORT=3000
Enter fullscreen mode Exit fullscreen mode

Create the Express server

Now, we'll create an index.js file in the base directory and set up a simple Express server.

const express = require("express");
const dotenv = require("dotenv");
require('dotenv').config(); 

const app = express();
const port = process.env.PORT;

//middleware provided by Express to parse incoming JSON requests.
app.use(express.json()); 

app.get("/", (req, res) => {
  res.send("Hello World");
});

app.listen(process.env.PORT, () => { console.log(`Server is running on port ${process.env.PORT}`); });
Enter fullscreen mode Exit fullscreen mode

At the top of the project, we're loading environment variables using dotenv.config() to make them accessible throughout the file. This way, we can use the dotenv package to access the PORT number from the .env file, instead of hardcoding it directly.

Run the project

In this step, we'll add a start script to the package.json file to easily run our project.

By default we'd have to restart our server each time we make changes to a file. To avoid this, we can use nodemon, which will scan for changes and restart automatically.

Add the following item to package.json:

"scripts": {
  "start": "nodemon index.js"
}
Enter fullscreen mode Exit fullscreen mode

And now, we test!

npm run start
Enter fullscreen mode Exit fullscreen mode

Screenshot of npm run start, running successfully

Finally, open http://localhost:3000/ in a browser, and if everything went to plan, you'll see the default "Hello World" page.

Implementing authentication with JWT

Our basic project setup is done! Next, we’ll implement authentication using JWT tokens.

Create routes for authentication

The authentication route will allow users to sign up and log in. Commonly, routes go in routes/, and since this is an authentication mechanism, we'll name it authentication.js. I've split this into multiple sections to make it easier to read, but it's the same file.

require('dotenv').config(); 
const express = require('express'); 
const jwt = require('jsonwebtoken'); 
const bcrypt = require('bcryptjs');

const app = express(); 
app.use(express.json());

const users = [ ]; // in-memory database to keep things basic for this tutorial

// Register route

app.post('/signup, (req, res) = >{

  const { username, password, role } = req.body;

  if (!Array.isArray(role)) {
    return res.status(400).json({ message: "Role must be an array" });
  }

  const userExists = users.find(user = >user.username === username);
  if (userExists) {
    return res.status(400).json({
      message: 'User already exists'
    });
  }

  const hashedPassword = bcrypt.hashSync(password, 8);

  users.push({
    username,
    password: hashedPassword,
    role
  });

  res.status(201).json({
    message: 'User registered successfully'
  });
});
Enter fullscreen mode Exit fullscreen mode
// Login route 

app.post('/login', (req, res) = >{
  const {
    username,
    password
  } = req.body;

  const user = users.find(user = >user.username === username);
  if (!user) {
    return res.status(404).json({
      message: 'User not found'
    });
  }

  const isPasswordValid = bcrypt.compareSync(password, user.password);
  if (!isPasswordValid) {
    return res.status(401).json({
      message: 'Invalid credentials'
    });
  }

  const token = jwt.sign({
    username: user.username,
    role: user.role
  },
  process.env.JWT_SECRET, {
    expiresIn: '1h',
  });

  res.status(200).json({
    token
  });
});

Enter fullscreen mode Exit fullscreen mode

The signup route allows users to register by providing a username and password. Upon receiving the data, it creates a new user instance and saves it to the in-memory database.

And, the login route verifies the user's credentials against the in-memory database. If the username and password match, it generates a JWT token and returns it to the client.

Middleware for JWT verification

Now, we’ll add one middleware function called verifyJWT that verifies the JWT token sent by the client, and which authenticates the user.

const jwt = require('jsonwebtoken');

function authenticateToken(allowedRoles) {
  return (req, res, next) = >{
    const authHeader = req.headers['authorization'];
    const token = authHeader && authHeader.split(' ')[1];

    if (!token) {
      return res.status(401).json({
        message: No token provided'
      });
    }

    jwt.verify(token, process.env.JWT_SECRET, (err, user) = >{
      if (err) {
        return res.status(403).json({
          message: 'Invalid token'
        });
      }

      if (!allowedRoles.includes(user.role)) {
        return res.status(403).json({
          message: ‘You do not have the correct role'
        });
      }

      req.user = user;
      next();
    });
  };
}

module.exports = authenticateToken;
Enter fullscreen mode Exit fullscreen mode

Protected route

Finally, we'll integrate these authentication routes with our express application in the index.js file.

app.get("/protected", authenticateToken(["admin"]), (req, res) => {
  res.status(200).json({ message: `Welcome Admin ${req.user.username}!` });
});
Enter fullscreen mode Exit fullscreen mode

Here we have created a /protected route that uses authenticateToken to check if the user is authenticated.

If the user is authenticated, the server sends back a response saying "This is a Protected Route. Welcome [username]".

Here, [username] is replaced with the username of the authenticated user.

Test the endpoints

Sign-up

To validate the sign-up endpoint, we'll make a POST request to this route:

  • http://localhost:3000/auth/signup

With this header:

  • Content-Type : application/json

And the following JSON body:

{
    "username": "Arindam Majumder",
    "password": "071227",
    "roles":["admin"]
}
Enter fullscreen mode Exit fullscreen mode

Screenshot of a postman interaction

We got the token back as a response. We will copy the token as we'll need it in the next section.

Protected route

Now that we have obtained a JWT token from the login endpoint, we will test the protected route by including this token in the request headers.

For that, we'll make a GET request to the protected route URL http://localhost:3000/protected. In the request headers, we'll include the JWT token obtained from the login endpoint:

Screenshot of a postman interaction

With this, we have successfully implemented JWT authentication in Node.js!

Implementing authorization with Cerbos

Now that we have added authentication to our project, we can now proceed authorization using the Cerbos PDP.

Launch the Cerbos container

We will begin by launching the Cerbos container via Docker.

In the base directory, use the following command to run the Docker container:

docker run --rm --name cerbos -d -v $(pwd)/cerbos/policies:/policies -p 3592:3592 -p 3593:3593 ghcr.io/cerbos/cerbos:0.34.0 
Enter fullscreen mode Exit fullscreen mode

And a quick docker ps to verify that the container persists.

For more information about the Cerbos container, see the official documentation.

Initialize the Cerbos client

Next, we set up the Cerbos client using the @cerbos/grpc library, which we first need to install:

npm install @cerbos/grpc
Enter fullscreen mode Exit fullscreen mode

And we initialize the client with a few lines of code:

const { GRPC } = require("@cerbos/grpc");
const cerbos = new GRPC("localhost:3593", { tls: false });
Enter fullscreen mode Exit fullscreen mode

Define a CRUD policy

After initializing the Cerbos client, we’ll define a simple CRUD policy in place for our application. Add this basic policy into the policies folder at cerbos/policies/contact.yaml.

---
apiVersion: api.cerbos.dev/v1
resourcePolicy:
  version: default
  resource: contact
  rules:
  - actions: ["read"]
    roles:
      - admin
      - user
    effect: EFFECT_ALLOW

  - actions: ["create"]
    roles:
      - admin
    effect: EFFECT_ALLOW
Enter fullscreen mode Exit fullscreen mode

Access control middleware

Next, we'll create some middleware to check access for requests. In middleware/access.js:

const { GRPC } = require("@cerbos/grpc");
const cerbos = new GRPC("localhost:3593", { tls: false });

const jwtToPrincipal = ({ id, iat, roles = [], ...rest }) => {
  return {
    id: id,
    roles,
    attributes: rest,
  };
};

async function readAccess(req, res, next) {
  const decision = await cerbos.checkResource({
    principal: jwtToPrincipal(req.user),
    resource: {
      kind: "contact",
      id: req.user.id,
      attributes: req.user,
    },
    actions: ["read"],
  });

  if (decision.isAllowed("read")) {
      next();
    } else {
      return res.status(403).json({ error: "Unauthorized" });
    }

  }

  async function writeAccess(req, res, next) {
    const decision = await cerbos.checkResource({
      principal: jwtToPrincipal(req.user),
      resource: {
        kind: "contact",
        id: req.user.id,
        attributes: req.user,
      },
      actions: ["create"],
    });

    if (decision.isAllowed("create")) {
      next();
    } else {
      return res.status(403).json({ error: "Unauthorized" });
    }
  }

module.exports = {readAccess, writeAccess};
Enter fullscreen mode Exit fullscreen mode

Here, the jwtToPrincipal function is a utility function that converts a JSON Web Token (JWT) payload into a Cerbos principal object. It extracts the sub (subject), iat (issued at), and roles properties from the JWT payload and assigns them to the corresponding properties of the principal object

The readAccess function is an asynchronous middleware function that checks if the current user has permission to read a contact resource. It uses the cerbos.checkResource method to make an authorization decision.

The writeAccess function is similar to the readAccess function, but it checks for permission to write to the contact resource instead of reading. It uses the "write" action in the cerbos.checkResource method.

Implement authorized routes

Finally, let's implement routes to check user access using Cerbos.

Import the required middleware functions for read and write access checks:

const {readAccess,writeAccess} = require("./middleware/access");
Enter fullscreen mode Exit fullscreen mode

Update index.js file to include the following routes:

app.get("/read", userMiddleware, readAccess, (req, res) => {
  res.send("Read Access Granted");
});

app.post("/write", userMiddleware, writeAccess, (req, res) => {
  res.send("Write Access Granted");
});
Enter fullscreen mode Exit fullscreen mode

And that’s it! By following these steps, we have successfully implemented authorization and RBAC in our Node.js application using Cerbos.

Test the authorized route

Now, we’ll test to see if the user has permission to do specific tasks.

To check if the user has write access, we'll make a POST request to http://localhost:3000/write with the following headers:

  • Content-Type: application/json
  • Authorization: JWT_TOKEN

Screenshot of a postman interaction

The user with the admin role has access to this route.

Next, to check if the user has read access, we'll make a GET request to http://localhost:3000/read with the following headers:

  • Content-Type: application/json
  • Authorization: JWT_TOKEN

If successful, that will return as Read Access Granted as well.

Finally, let's check a user with the user role to see if it has write access or not. Make a POST request to http://localhost:3000/write with the following headers

  • Content-Type: application/json
  • `Authorization: JWT_TOKEN

As expected, we got an unauthorized response. That means the user doesn’t have access to that route.

Conclusion

In this article, we covered the basics of authentication and authorization, and how to integrate JWT authentication and Cerbos authorization into a Node.js application.

BlogCTA - Hub

Top comments (4)

Collapse
 
lisadziuba profile image
Lisa Dziuba

Thanks for sharing, very detailed and practical guide!

Collapse
 
phrawzty profile image
Dan

Thanks! I'm just really glad to have been able to share something from our community. :)

Collapse
 
alexander_diatlov_05a3c99 profile image
Alexander Diatlov

great tutorial btw

Collapse
 
alexander_diatlov_05a3c99 profile image
Alexander Diatlov

Is the authorization solution used in the guide open source, right?

Some comments may only be visible to logged-in visitors. Sign in to view all comments. Some comments have been hidden by the post's author - find out more