How to Implement Authorization in an Express Application

gemanor

Gabriel L. Manor

Posted on May 30, 2023

How to Implement Authorization in an Express Application

Introduction

It's hard to imagine the Node.JS ecosystem without the Express framework. Not only is it one of the most popular frameworks, but it also inspired many others. Almost every modern web framework today contains a flavor that originated from Express.

If you ever use Express in production, there is a good chance you will need to embed it with an authorization/permission solution - A middleware that can determine what your users can and can't do within your Express endpoints.

Even though it always starts simple, as the one-line ticket states: Add RBAC to the project, it always ends up with writing a lot of code, and annoying bugs that come with it.

In this article, I suggest a new, efficient way to deal with permissions in Express applications. I will demonstrate how to use Permit to create lean and fast authorization middleware for your Express application. By the end of this article, you will be able to implement a much better authorization solution for your application with much less code and bugs. Let's dive in.

The Homebrewed Authorization Problem

One of the main reasons for the authorization mess we describe is the usage of policy logic inside the middleware. While in an ideal world, the middleware will only enforce the policy, in the real world, it is also responsible for the policy logic. Let's take a classic code example: In the beginning, you only have one row that checks the user role and verifies the DB for the permissions:

if (user.role === 'admin') {
    next();
} else {
    res.status(403).send('Forbidden');
}
Enter fullscreen mode Exit fullscreen mode

But then, you need to add a new role, so you add a new row to the middleware:

if (user.role === 'admin' || user.role === 'manager') {
    next();
} else {
    res.status(403).send('Forbidden');
}
Enter fullscreen mode Exit fullscreen mode

Then, you need to add a new permission, and you add a new row to the middleware:

if (user.role === 'admin' || user.role === 'manager' || user.role === 'user' && user.permissions.includes('read')) {
    next();
} else {
    res.status(403).send('Forbidden');
}
Enter fullscreen mode Exit fullscreen mode

Continuing this pattern, you’ll end up with a huge middleware that is hard to maintain and hard to test. There are two best practices that can help you avoid this mess:

  1. Design your permissions model in a way that does not depend on the application implementation. Instead of ‘Add RBAC to the project’, think about ‘Design a Permission Model for the Application’ and only then implement the details that are relevant to you.

  2. Do not use policy logic inside the middleware. Instead of it, use a dedicated service that will be responsible for the policy logic. We'll discuss how you can solve it with Permit.io later.

We will start with designing your application’s permissions model. To do that, let’s look at a demo application:

A Demo Application

For the purpose of this article, we will use an Express-based blogging application. The application code is available on GitHub and we encourage you to clone it and work on this tutorial interactively. The code consists of three files:

app.js - The main application file. Consist of the relevant API endpoints for a working blogging application (the real function is just a mock).

...
app.get('/post', mockPublic);
app.get('/post/:id', mockPublic);
app.post('/post', authentication, mockPrivate);
app.put('/post/:id', authentication, mockPrivate);
app.delete('/post/:id', authentication, mockPrivate);

app.get('/author', mockPublic);
app.get('/author/:id', mockPublic);
app.post('/author', authentication, mockPrivate);
app.put('/author/:id', authentication, mockPrivate);
app.delete('/author/:id', authentication, mockPrivate);

app.get('/comment', mockPublic);
app.get('/comment/:id', mockPublic);
app.post('/comment', authentication, mockPrivate);
app.put('/comment/:id', authentication, mockPrivate);
app.delete('/comment/:id', authentication, mockPrivate);
...
Enter fullscreen mode Exit fullscreen mode

middleware/authentication.js - The authentication middleware. A simple authentication mock will add a user object from a JWT token to the request object.

...
const authentication = (req, res, next) => {
    const authHeader = req.headers['authorization'];
    const token = authHeader && authHeader.split(' ')[1];
    if (token == null) return res.sendStatus(401);

    jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => {
        if (err) return res.sendStatus(403);
        req.user = user;
        next();
    });
};
...
Enter fullscreen mode Exit fullscreen mode

app.test.js - The test file. A simple test file that will verify that the application is working as expected.

...
// Init a token for authenticated requests
const token = 'Bearer ' + jwt.sign({ username: 'admin@permit-blog.app' }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' });
...
test('CRUD Posts', async () => {
    await request(app).get('/post').expect(200);
    await request(app).get('/post/1').expect(200);
    await request(app).post('/post').expect(401);
    await request(app).put('/post/1').expect(401);
    await request(app).delete('/post/1').expect(401);

    await request(app).post('/post').set('Authorization', token).expect(200);
    await request(app).put('/post/1').set('Authorization', token).expect(200);
    await request(app).delete('/post/1').set('Authorization', token).expect(200);
});
...
Enter fullscreen mode Exit fullscreen mode

To see it in action, run the following commands (assuming you have Node.JS and NPM installed on your machine):

1. Clone the repository in your desired location

git clone -b tutorial git@github.com:permitio/permit-express-tutorial.git
Enter fullscreen mode Exit fullscreen mode

2. Install the dependencies

npm install
Enter fullscreen mode Exit fullscreen mode

3. Run the tests that will verify that everything is working as expected

npm run test
Enter fullscreen mode Exit fullscreen mode

As you can see in the output, the tests are passing and the application is working as expected.

Designing a Permission Model

When integrating permissions into an application, it is crucial to carefully design the model and determine which permissions should be granted to users.

To achieve this, we consider three key components: the user's identity and role , the resources being accessed, and the actions that can be performed on those resources.

The amalgamation of these components is referred to as a " Policy."

Keeping these entities in mind, we can translate our application requirements into specific conditions and policies that reflect the desired permissions.

Let's analyze the APIs of our demonstration blog:

  • Roles can be assigned to authenticated users, such as Admin, Writer, and Commenter.

  • Actions can be associated with HTTP methods (Get, Create, Update, Delete, Patch) to simplify the process.

  • Resources represent the various endpoints we need to manage access for, including posts, authors, comments, and so on.

By mapping all these elements, we can create the following table:

Role Resource Action
Admin Post Get
Writer Author Create
Commenter Comment Update
Delete
Patch

Having established the foundational roles, resources, and actions, we can now map the desired permissions based on the principle of least privilege:

  • Admins have the ability to perform any action on any resource.

  • Writers can create, update, patch, and delete posts, as well as retrieve comments.

  • Commenters can retrieve and create comments.

By adhering to these defined conditions, we ensure that the permission model follows the principle of least privilege, granting users only the necessary access required for their respective roles and tasks.

Configuring the Permissions Model with Permit.io

Now that our model is designed, it's time to put it into action! As mentioned before, we don't want to mix the policy code with the API logic. To maintain a clean structure, we'll utilize a separate service specifically designed for defining and configuring policies. This approach allows the service to focus on enforcing permissions while the application code handles critical application logic.

Permit.io, provides authorization-as-a-service, streamlines permission configuration and enforcement, and ensures your code remains organized, and access to your application is controlled. The platform offers an extensive free tier and operates on a self-service basis.

To configure the desired application permissions, follow these steps:

1. Log in to Permit.io here

2. Once logged in, navigate to the Policy page and create the following roles:

Create roles blog.png
3. Proceed by creating the necessary resources along with their respective actions:

Resources Blog.png
4. Customize the policy table by implementing the desired conditions through the selection of relevant checkboxes:

Policy editor blog.png
5. To complete the configuration, create three user accounts and assign them the appropriate roles using the Users screen:

Users blog.png

That's it! Now that we've established our permissions, it's time to integrate them with our Express application.

Using the Permit.io SDK in the Application

Now that we configured the permissions model in Permit.io, we can use it in our application. As you remember, our main goal is to keep the middleware as lean as possible. Make it only check and enforce the permissions against the configuration made in Permit.io.

Install and Initialize Permit.io SDK

1. Before we start using Permit.io, we need to install the Permit.io SDK.

⁠npm install permitio
Enter fullscreen mode Exit fullscreen mode

2. Now that we have the SDK installed, we need to get the SDK key from Permit.io. Go to the Settings page, and grab the SDK key.

sdk_key.png
3. Let's save the SDK key in the .env file at the root of the project

⁠PERMIT_SDK_SECRET=YOUR_SDK_KEY
Enter fullscreen mode Exit fullscreen mode

4. With our SDK key configured, let's create a new file called middleware/authorization.js and add the following code to it:

const permit = require('permitio');

const permit = new Permit({
    token: process.env.PERMIT_SDK_SECRET,
    pdp: process.env.PDP_URL
});

const authorization = (req, res, next) => {

}

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

Add Authorization to the Application

1. With empty authorization middleware set up, we can add the middleware to the relevant protected routes in the app.js file.

const authorization = require('./middleware/authorization');

...
app.post('/post', authentication, authorization, mockPrivate);
app.put('/post/:id', authentication, authorization, mockPrivate);
app.delete('/post/:id', authentication, authorization, mockPrivate);
...
app.post('/author', authentication, authorization, mockPrivate);
app.put('/author/:id', authentication, authorization, mockPrivate);
app.delete('/author/:id', authentication, authorization, mockPrivate);
...
app.post('/comment', authentication, authorization, mockPrivate);
app.put('/comment/:id', authentication, authorization, mockPrivate);
app.delete('/comment/:id', authentication, authorization, mockPrivate);
...
Enter fullscreen mode Exit fullscreen mode

2. Let's run the tests again and see that everything is still passed (remember our authorization logic is still empty).

npm test
Enter fullscreen mode Exit fullscreen mode

3. Now, let's add the logic to the middleware. In the function, add a call to permit.check with the user, action, and resource we want to call.

...
const action = method.toLowerCase(),
    url_parts = url.split('/'),
    type = url_parts[1],
    key = url_parts[2] || null;
const allowed = await permit.check(username, action, {
    type,
    key,
    attributes: body || {}
});

if (!allowed) {
    res.sendStatus(403);
    return;
}
next();
...
Enter fullscreen mode Exit fullscreen mode

4. Let's rerun the tests and see the failed results as our tests configured user has no admin role:

npm test
#  FAIL  ./app.test.js
#   API Test
#     ✕ CRUD Post (802 ms)
#     ✕ CRUD Author (615 ms)
#     ✕ CRUD Comment (664 ms)
#   ● API Test › CRUD Post
#     expected 200 "OK", got 403 "Forbidden"
Enter fullscreen mode Exit fullscreen mode

5. To fix the tests, let's change the username in our tests to: admin@permit-blog.app

...
const token = 'Bearer ' + jwt.sign({ username: 'admin@permit-blog.app' }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' });
...
Enter fullscreen mode Exit fullscreen mode

6. Tests should now pass!

npm test
Enter fullscreen mode Exit fullscreen mode

Test the Permission Model

At this point, as we protected all the endpoints, we can test the permission model.

1. Let's add more tokens for different users at the beginning of the test file.

...
    const token = 'Bearer ' + jwt.sign({ username: 'admin@permit-blog.app' }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' });
    const writer = 'Bearer ' + jwt.sign({ username: 'writer@permit-blog.app' }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' });
    const commenter = 'Bearer ' + jwt.sign({ username: 'commenter@permit-blog.app' }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' });
...
Enter fullscreen mode Exit fullscreen mode

2. And add the tests for the writer and commenter users.

...
await request(app).post('/post').set('Authorization', writer).expect(200);
await request(app).put('/post/1').set('Authorization', writer).expect(200);
await request(app).delete('/post/1').set('Authorization', writer).expect(200);
await request(app).post('/post').set('Authorization', commenter).expect(403);
await request(app).put('/post/1').set('Authorization', commenter).expect(403);
await request(app).delete('/post/1').set('Authorization', commenter).expect(403);
...
Enter fullscreen mode Exit fullscreen mode

Improve Authorization with ABAC

Note: Enforcing ABAC policies requires deploying a local PDP - to get started, follow this guide.

Streamlining our Identity, Resource, and Action into a concise list of Roles, Resource types, and Action names can pose challenges in real-world scenarios, as demonstrated in our previous example.

However, simple Role-Based Access Control (RBAC) may not be sufficient if we encounter more complex requirements. For instance, if we want to create an approval flow for blog content, allowing only approved writers to publish articles and restricting comments from specific geolocations, or applying other fine-grained limitations, we need to consider Attribute-Based Access Control (ABAC).

Let's delve into the details of these conditions by incorporating attributes:

  • Admin users possess unrestricted access, enabling them to perform any action on any resource.

  • Writers have the ability to edit and delete posts, but they can only create unpublished posts.

  • Approved writers enjoy the freedom to create any type of post.

  • Commenters are permitted to create comments.

Implementing ABAC by introducing attributes to an RBAC model can be a complex task. However, Permit.io simplifies this process by facilitating a configuration change to support the new permission model without requiring modifications to the application code.

An effective approach to implementing ABAC involves utilizing Resource Sets and User Sets, which are constructed based on conditions that combine user and resource attributes. Let's explore how Permit enables us to configure these policies:

1. First, to enable ABAC in Permit, go to the ABAC Rules tab in the policy editor and toggle the ABAC Options switch.

2. Begin by configuring attributes for the relevant resources. Access the Policy Editor, click the three dots on the resource table, and select "Add Attribute."

Resource attribute config blog.png
3. With the resource attributes defined, create Resource Sets within the Policy Editor to establish the necessary conditions.

Resource set config blog.png
4. To match the policy with user attributes, configure user attributes as well. Access the Users screen, navigate to the "Attributes" tab, and create the desired approved attribute.

Create user attributes blog.png
5. Create a new user in the Writer role and assign them the approved attribute in their profile. This user will serve as a reference for evaluating the ABAC policy later. Let's use the following username approved_writer@permit-blog.app

Create roles ABAC.png
6. Now that Permit.io recognizes the custom user attributes create User Sets in the Policy Editor to accommodate these conditions.

User Sets blog.png
7. With the conditions in place, adapt the policy configuration in the policy table to align with the newly defined conditions.

Policy editor ABAC blog.png

By adopting this approach, we can enforce permissions without the need to rewrite our application code. The middleware we initially developed for private routes will seamlessly continue its role by enforcing permissions based on the new policy model configuration we assigned.

Add Test to our New ABAC Policy

  1. By running our current tests, you can see that now our writer can't create a post.

    npm test
    #  FAIL  ./app.test.js (8.677 s)
    #   API Test
    #     ✓ CRUD Post (2909 ms)
    #     ✕ CRUD Post by writer and commenter (893 ms)
    #     ✓ CRUD Author (2146 ms)
    #     ✓ CRUD Comment (2256 ms)
    #   ● API Test › CRUD Post by writer and commenter
    #     expected 200 "OK", got 403 "Forbidden"
    #       24 |
    #       25 |     test('CRUD Post by writer and commenter', async () => {
    #     > 26 |         await request(app).post('/post').set('Authorization', writer).expect(200);
    
  2. Let's fix those tests by passing objects with the different published attributes.

    await request(app).post('/post').send({
    published: false,
    }).set('Authorization', writer).expect(200);
    await request(app).put('/post/1').send({
    published: false,
    }).set('Authorization', writer).expect(200);    
    
  3. Now, let's run the tests again and see that they are now passed.

    npm test
    #  PASS./ app.test.js
    #   API Test
    #     ✓ CRUD Post(132 ms)
    #     ✓ CRUD Post by writer and commenter(212 ms)
    #     ✓ CRUD Author(92 ms)
    #     ✓ CRUD Comment(87 ms)
    
  4. Now, let's test the approved users. We will add a new test for the approved user.

// Add a new token for the approved user we just created
const approvedWriter = 'Bearer ' + jwt.sign({ username: 'approved_writer@permit-blog.app' }, process.env.ACCESS_TOKEN_SECRET, { expiresIn: '15m' });
...
// Add a test case
test('CRUD Post by approved writer', async () => {
    await request(app).post('/post').send({
        published: true,
    }).set('Authorization', approved).expect(200);
    await request(app).put('/post/1').send({
        published: true,
    }).set('Authorization', approved).expect(200);
});
...
Enter fullscreen mode Exit fullscreen mode

As you can see, no changes to the application code were required to enforce the new policy. Permit.io's ABAC capabilities enable us to implement complex policies without the need to modify our application code.

What's Next?

By now, you should have a basic understanding of how to implement a basic authorization model into your Express application, enforcing permissions with just a single line of code.

The next step would be to analyze the specific requirements of your application and incorporate a dependable permission model into it. As demonstrated in the article, it doesn't have to be overly complicated.

The plugin we developed for this blog is readily available for use. Simply adjust it to accommodate the relevant request fields of your application, and you're good to go.

If your organization has already implemented an authorization model and you're interested in learning more about scaling it effectively, join our Slack community, where numerous developers and authorization experts discuss the process of building and implementing authorization.

💖 💪 🙅 🚩
gemanor
Gabriel L. Manor

Posted on May 30, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related