It's easy to get lost with dozens of plugins and frameworks when starting a new project that requires basic authentication and authorization capabilities. It doesn't have to be that way.
In this article, we're going to explore two valuable Node.js packages — Passport and CASL — that can help you boost the security of your application by providing both authentication and authorization functionality.
This pair of packages is commonly used, and loved, by many Node.js developers in their backend applications.
At the end of the post, we'll have a better understanding of what each one is capable of through the implementation of a practical sign-in/authorization example app. Let's dive in!
Setting Up Our Node.js Project
We're going to explore authentication and authorization concepts while building them, so let's start off by setting up a new project.
First, create a new Node.js project in a folder of your preference and configure it according to the following package.json:
1{
2 "name": "express-passport-casl-example",
3 "version": "0.0.1",
4 "description": "Express app with Passport for authentication and CASL for authorization.",
5 "author": "AppSignal",
6 "dependencies": {
7 "@casl/ability": "^5.2.2",
8 "body-parser": "^1.19.0",
9 "bootstrap": "^4.6.0",
10 "connect-ensure-login": "^0.1.1",
11 "ejs": "^3.1.6",
12 "express": "^4.17.1",
13 "express-session": "^1.17.1",
14 "morgan": "^1.10.0",
15 "passport": "^0.4.1",
16 "passport-local": "^1.0.0"
17 }
18}
We'll create the app's UI with EJS, and style it with Bootstrap, so we've included these libs.
For the sake of simplicity, we're not going to use a real database. To emulate the data, we'll use an in-memory database with a fake list of users generated from the https://randomuser.me/ open-source website.
For this, create a new folder and file called db/users.js and add the following:
1var users = [
2 {
3 id: 1,
4 username: "brad",
5 password: "admin",
6 name: {
7 first: "Brad",
8 last: "Gibson",
9 },
10 address: {
11 street: { number: 12, name: "Start St" },
12 city: "Kilcoole",
13 postcode: "93027",
14 },
15 email: "brad.gibson@example.com",
16 phone: "011-962-7516",
17 picture: "https://randomuser.me/api/portraits/men/75.jpg",
18 },
19 {
20 id: 2,
21 username: "zach",
22 password: "admin",
23 name: { first: "Zachary", last: "Wilson" },
24 address: {
25 street: { number: 5189, name: "Simcoe St" },
26 city: "Springfield",
27 postcode: "98448",
28 },
29 email: "zachary.wilson@example.com",
30 phone: "600-887-7510",
31 picture: "https://randomuser.me/api/portraits/men/4.jpg",
32 },
33 {
34 id: 3,
35 username: "anonymous",
36 password: "anonymous",
37 name: {
38 first: "Anonymous",
39 },
40 },
41];
42
43exports.findUser = function (id, callback) {
44 process.nextTick(function () {
45 var idx = id - 1;
46 if (users[idx]) {
47 callback(null, users[idx]);
48 } else {
49 callback(new Error(`User ${id} does not exist`));
50 }
51 });
52};
53
54exports.findUserByUsername = function (username, callback) {
55 process.nextTick(function () {
56 for (var i = 0, len = users.length; i < len; i++) {
57 var user = users[i];
58 if (user.username === username) {
59 return callback(null, user);
60 }
61 }
62 return callback(null, null);
63 });
64};
Notice that some of the data was reorganized to better adapt to a JavaScript object rather than simple JSON.
The password comes in plain text as well, so be aware of that when migrating your example to use a real database.
We've created 3 users to make sure we can later set up three different situations for them, like an anonymous user, for example, that can log into the app but won't have permission to see anything.
Finally, create a file named index.js in the same folder and export the users:
1exports.users = require("./users");
Profile Model
The example app we’re building will have four pages: a sign-in page, the homepage, the profile page, and an errors page.
The profile page will make use of a business entity object that is going to be useful when CASL needs to authorize access to it.
So, create a new folder called models, then add the following to the file profile.js created in the folder:
1class Profile {
2 constructor(props) {
3 Object.assign(this, props);
4 }
5}
6
7module.exports = Profile;
That’s just a model object that represents a profile whose properties are going to be defined dynamically.
Introducing Passport
Passport is a powerful Node.js middleware for Express-based apps that offers a lot of flexibility when dealing with the authentication of requests.
It integrates with all major protocols such as OpenID, OAuth 2.0, etc., and makes use of a concept called strategies to authenticate your requests.
To keep things simple, we're going to focus on the local HTML form strategy, which allows you to handle the authentication of your app by yourself, without interference from external providers.
For our example, the strategy will simply verify the username/password information provided within the requests. However, Passport can handle automatic authentication via Facebook or Google OAuth, for example.
All configs must be set up before the server starts up and Express routes are served. To define Passport's configs, let's create a new folder and file called auth/index.js at the root of the project.
The following code listing shows the file's content:
1const Strategy = require("passport-local").Strategy;
2const passport = require("passport");
3const db = require("../db");
4
5// Passport local strategy
6passport.use(
7 new Strategy(function (username, password, callback) {
8 db.users.findUserByUsername(username, function (err, user) {
9 if (err) {
10 return callback(err);
11 }
12 if (!user) {
13 return callback(null, false);
14 }
15 if (user.password != password) {
16 return callback(null, false);
17 }
18 return callback(null, user);
19 });
20 })
21);
22
23// Passport authenticated session.
24passport.serializeUser(function (user, callback) {
25 callback(null, user.id);
26});
27
28passport.deserializeUser(function (id, callback) {
29 db.users.findUser(id, function (err, user) {
30 if (err) {
31 return callback(err);
32 }
33 callback(null, user);
34 });
35});
36
37module.exports = {
38 configure(app) {
39 // Init Passport and restore auth
40 app.use(passport.initialize());
41 app.use(passport.session());
42
43 return passport;
44 },
45};
Since Passport’s local strategy is password-based, we always need to receive the username and password as the first and second params of the Strategy
object, which are needed to check if the user is properly authenticated. As we don’t have any database layer going on, the check goes directly to the in-memory database list. If everything’s alright, the code calls the callback
function that was also passed in as a param, which allows Express to move on with the request.
For the sake of security, we can't pass over the user's credentials every time a new request is performed, however, we still need to authenticate that same user for every request. As you may see at the end of the listing, we make use of Passport's login session, which works by creating a cookie set in the browser on the first successful user login attempt.
This way, all subsequent requests will only contain this cookie which will help Passport identify the right session for that particular user.
To be able to do so, Passport requires that we specify two functions for serializing and deserializing the user's data. As you can see in the code, we only store the user id to avoid over-inflating the session since the id is all we need to find the right user from the in-memory database.
Enabling sessions is optional, but it's highly recommended that you do so to allow Passport to keep the user logged in when they close the window and access it later.
Another good way of handling this is through OAuth 2.0 strategy, whereby, when starting the login flow, the user is redirected to a service provider (it could be a third-party, e.g. Facebook or Google, or an in-house provider) that will authorize access which, if granted, will redirect the user back to your application with a code in hand. Your application then makes use of this code by exchanging it with an access token which, in turn, will be the only information transferring from client to server. Since the user's credentials aren't being transferred, the app's requests are safer. Plus, there's no need for a session anymore. You can read more on Passport's OAuth 2.0 strategy here.
Moving on, let's attach the Passport configs that we talked about to the Express server by creating a new file named server.js at the root folder and add the following code to it:
1const express = require("express");
2const app = express();
3
4// Session handling
5app.use(
6 require("express-session")({
7 secret: "appsignal secret",
8 resave: false,
9 saveUninitialized: false,
10 })
11);
12
13// Logging, body parsing
14app.use(require("morgan")("combined"));
15app.use(require("body-parser").urlencoded({ extended: true }));
16
17// Passport auth
18const passport = require(`./auth`).configure(app);
19
20const PORT = process.env.PORT || 3000;
21app.listen(PORT);
22console.debug(`Server listening on port: ${PORT}`);
To enable Passport session, we also have to make use of Express's session middleware via the express-session package. We pass it three parameters:
secret
: this is the only required param and, as you may guess, it is the secret that will be used to sign the session ID cookie. You can define whatever string you want (or array of strings), however, remember that this is an example. In real-world apps, you must store it in a safer location such as a remote config file or environment.resave
: this param allows Express to force a session to be saved into the session store when new requests arrive regardless of whether the session was modified during the request or not. We're only setting this tofalse
because it defaults totrue
and, in the case of Passport, it handles store resaving on its own.saveUninitialized
: in a similar way to the previous param, this one is responsible for forcing a session in an "uninitialized" (i.e. it is new and untouched) state to be saved back to the store. Again, Passport already takes care of this, so let's default tofalse
.
Simple, isn’t it? We’re also adding some extra features like default request logging via morgan, and the express-session needed by Passport to support user-awareness features.
However, this authentication isn’t useful if we don’t have endpoints exposed in our Express app. Before we fix that, let’s understand what CASL’s about!
Introducing CASL
CASL is a JavaScript authorization library that restricts the resources a given user is allowed to access. It’s a very powerful and broad framework since it allows integration with many major ecosystems such as React, Angular, Vue, integration via a database with Mongoose, as well as API-based integration for Express apps.
To add authorization capabilities to your APIs, CASL makes use of the abilities concept, which is a mapping between users and their permissions.
After you set up the abilities, which can also be configured dynamically or even retrieved from the database, the other layers that need to check for permission can turn to CASL for it.
Let’s now configure CASL by creating another folder and file called authz/abilities.js and adding the following to it:
1const { AbilityBuilder, Ability } = require("@casl/ability");
2
3function defineRulesFor(user) {
4 const { can, rules } = new AbilityBuilder(Ability);
5
6 // If no user, no rules
7 if (!user) return new Ability(rules);
8
9 switch (user.id) {
10 case 1:
11 can("manage", "all");
12 break;
13 case 2:
14 can("read", "Profile", {
15 id: user.id,
16 });
17 break;
18 default:
19 // anonymous users can't do anything
20 can();
21 break;
22 }
23
24 return new Ability(rules);
25}
26
27module.exports = {
28 defineRulesFor,
29};
Here, we’re hardcoding the authorizations. It’s just a way to simply demonstrate how CASL’s mapping works and how you can associate a Profile
, for instance, to a user (e.g. the user with id 2).
CASL comes with some pre-built permissions, such as manage
, that states that a user has permission to all actions in the system, i.e., they're an admin user.
Other common permissions relate to the usual CRUD operations: create
, read
, update
, and delete
. However, you can create custom app-level permissions, such as publish
, log
, etc.
The function can()
defines this mapping process by receiving the permission (or list of permissions) as the first param, and the permission target (or list of targets) as the second param. The targets are the objects we’re stating that a given user has permission to.
To ensure that each logged-in user has the right set of permissions, we may call this config every time a user signs in. For this, create a new index.js file in the authz folder and add the following code into it:
1const abilities = require("./abilities");
2
3module.exports = {
4 configure(app) {
5 app.use((req, _, next) => {
6 req.ability = abilities.defineRulesFor(req.user);
7 next();
8 });
9 },
10};
Call the code in the server.js
file, right after Passport config:
1...
2
3// Passport auth
4const passport = require(`./auth`).configure(app);
5// CASL authz
6require(`./authz`).configure(app);
7
8...
Profile API
We now need to set up the routes for our Express app. After all, there’s no use for authenticating users if not to access endpoints. The same goes for authorization.
To do this, let’s first create a new folder called routes at the root of the project. Then, add the following code for the routes mapping into a new file named index.js:
1const { ForbiddenError } = require("@casl/ability");
2const Profile = require("../models/profile");
3
4module.exports = {
5 configure(passport, app) {
6 app.get("/", function (req, res) {
7 res.render("home", { user: req.user });
8 });
9
10 app.get("/login", function (req, res) {
11 res.render("login");
12 });
13
14 app.post(
15 "/login",
16 passport.authenticate("local", { failureRedirect: "/login" }),
17 function (_req, res) {
18 res.redirect("/");
19 }
20 );
21
22 app.get("/logout", function (req, res) {
23 req.logout();
24 res.redirect("/");
25 });
26
27 app.get(
28 "/profile",
29 require("connect-ensure-login").ensureLoggedIn(),
30 function (req, res) {
31 const profile = new Profile({
32 ...req.user,
33 });
34
35 ForbiddenError.from(req.ability).throwUnlessCan("read", profile);
36
37 res.render("profile", profile);
38 }
39 );
40
41 app.put(
42 "/profile",
43 require("connect-ensure-login").ensureLoggedIn(),
44 function (req, res) {
45 const profile = new Profile({
46 ...req.user,
47 });
48
49 ForbiddenError.from(req.ability).throwUnlessCan("update", profile);
50
51 res.render("profile", profile);
52 }
53 );
54 },
55};
This is a crucial file in our project. This is where we gather both Passport and CASL settings to determine all the app endpoints and their respective auth and authz features.
The first four endpoints take care of authenticating the users, providing a login and logout entry point to the system, and returning the proper EJS views for each scenario. The /login
endpoint, specifically, also needs to call Passport’s authenticate
method passing it the chosen strategy (local) and a fallback URL in case login fails.
This is important because you can easily set up the page or even parameters that Passport will pass to your Express fail route in case anything bad happens.
The last two endpoints take care of the /profile
URL for both retrieving and updating a profile. Note that we’re always calling the connect-ensure-login
’s method to ensure the user is still authenticated based on the current session. This is all done automatically by Passport.
Their functions are, however, checking CASL's ability to see if the logged-in user has access to read or update on that profile for every request. The class ForbiddenError
helps us with the authorization check, throwing an exception in case access isn't allowed there.
Speaking of exceptions, CASL doesn’t have any way of letting EJS know that an authorization exception has occurred other than throwing an error. Because of that, we’ll have to implement an error-handling strategy, otherwise, the UI will have a response similar to the one shown below:
Forbidden error thrown by CASL checksWe can easily solve that by creating a new file called error-handler.js at the root folder with the following code:
1const { ForbiddenError } = require("@casl/ability");
2
3module.exports = function errorHandler(error, _req, res, _next) {
4 if (error instanceof ForbiddenError) {
5 return res.render("error", {
6 error: `You don't permission to ${error.action} this ${error.subjectType}`,
7 });
8 }
9};
That’s a great way to have customized error handling that won’t break the UI.
Finally, make sure your server.js configs match the following:
1const express = require("express");
2const app = express();
3
4const errorHandler = require("./error-handler");
5
6// EJS templates
7app.set("views", __dirname + "/views");
8app.set("view engine", "ejs");
9
10// Logging, parsing, session handling
11app.use(require("morgan")("combined"));
12app.use(require("body-parser").urlencoded({ extended: true }));
13app.use(
14 require("express-session")({
15 secret: "appsignal secret",
16 resave: false,
17 saveUninitialized: false,
18 })
19);
20
21// Passport auth
22const passport = require(`./auth`).configure(app);
23// CASL authz
24require(`./authz`).configure(app);
25// Models
26require(`./routes`).configure(passport, app);
27
28// Must be after the routes
29app.use(errorHandler);
30
31const PORT = process.env.PORT || 3000;
32app.listen(PORT);
33console.debug(`Server listening on port: ${PORT}`);
Testing
Before we test the app, we need the views. Views are known to be verbose due to the amount of HTML, CSS, and external code. So, to simplify our post, I’ll let you copy them from the GitHub repo since the EJS files don’t have anything special.
Make sure that all Node.js dependencies were already downloaded via npm install
, and run the following command to start the app:
1npm run start
When it starts up, go to http://localhost:3000/ and the following screen will appear:
Welcome page at localhostWhen you click to log in, the screen below will show up. Remember that they don’t have any authentication or authorization checks because they are public pages.
Login page at localhostLet’s try to log in with Brad, the first user on our in-memory database whose username is brad and password’s admin. When you hit the submit button, you’ll be redirected to the following screen:
Homepage at localhostIt’s important to note that the backend holds the session. So, if you close your browser window and open it again, you’ll see that you’re still logged in, which is the behavior we want.
The homepage isn’t verifying any permissions. The permission check starts at the profile page. Let’s test Brad’s permissions by clicking on the View your profile button. You’ll be redirected to the following screen:
Brad’s profile page with user’s informationLet’s log out now and test what happens when we try the same flow with the user anonymous
and password anonymous
that we added in our in-memory database list, remember?. The following will be the outcome for accessing the profile page:
Conclusion
You can find the source code for this tutorial here.
You can adjust the error handling and permission control as much as you want. As we’ve seen, both Passport and CASL give you enough flexibility to handle various use cases.
Authenticating and authorizing users doesn’t have to be painful, you just need to match the right pieces of the puzzle. Now’s your turn. Fork this project, and add new features, such as database integration. That’s a great start!
Looking forward to seeing what you guys come up with!
P.S. If you liked this post, subscribe to our new JavaScript Sorcery list for a monthly deep dive into more magical JavaScript tips and tricks.
P.P.S. If you need an APM for your Node.js app, go and check out the AppSignal APM for Node.js.