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:
- Role-based access control (RBAC)
- Attribute-based access control (ABAC)
- Policy-based access control (PBAC)
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.
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.
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
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
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
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}`); });
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"
}
And now, we test!
npm run start
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'
});
});
// 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
});
});
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;
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}!` });
});
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"]
}
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:
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
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
And we initialize the client with a few lines of code:
const { GRPC } = require("@cerbos/grpc");
const cerbos = new GRPC("localhost:3593", { tls: false });
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
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};
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");
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");
});
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
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.
Top comments (4)
Thanks for sharing, very detailed and practical guide!
Thanks! I'm just really glad to have been able to share something from our community. :)
great tutorial btw
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