Dejan Lukić

Build Role-Based Access Control (RBAC) in Next.js with Oso

Originally written for Oso.

Introduction

With complex applications becoming a norm, the right level of access to your application is essential for security and proper overall functionality. A rigid access system is a must to prevent unauthorized access to sensitive information.

Next.js is an open-source framework for building server-side rendered React applications. It is built on top of Node.js and provides several features that make creating fast, SEO-friendly web applications easy.

This article aims to implement a Role-based Access Control (RBAC) system in Next.js using Oso - an authorization provider - in TypeScript.

Oso working principle diagram

Note: This article will mention both authentication and authorization.

”Authentication is the mechanism for verifying who a user is. A username and password, for instance, verify that you can log in to an account.

Authorization is the mechanism for controlling what a user can do. For instance: after you’ve logged in, do you have access to a particular private git repository on GitHub?”

Prerequisites

To get started, make sure you have the following installed:

Setting up the Next.js project

To set up this project, you will need to install Next.js and all dependencies required.

Open your terminal window and start by creating a new Next.js project using the following command:

$ npx create-next-app@latest --ts

This will create a new Next.js project with TypeScript. Now, change your directory inside that project.

$ cd <name of your project>

Next, you will need a few dependencies to get running. You’ll need oso-cloud - an Oso API wrapper for Node.js and dotenv for managing the environment variables. You might also need next-auth to handle authentication providers depending on your authentication preferences.

$ npm i --save oso-cloud dotenv 

This will set up the project and install all necessary dependencies.

Type Definitions

Now, in the src folder, create a new folder types. In this folder, we will store types and interfaces required to ensure type safety within the application.

Create a new file inside the src/types folder interfaces.ts. Inside the file, define the User interface:

export interface User {
    name?: string | null | undefined;
    role?: string;
}

If you’re using next-auth for authentication, you will also need to create next-auth.d.ts in the same folder and declare the module with the Session interface.

import NextAuth from "next-auth";
import { User } from "./interfaces";

declare module "next-auth" {
  /**
   * Returned by `useSession`, `getSession` and received as a prop on the `SessionProvider` React Context
   */
  interface Session {
    user: User;
  }
}

Now, you’re ready to set up Rules in Oso and write some actual code.

Setting up Rules in Oso

In this step, you will set up a simple policy (Rule) allowing a single user to perform a specific action on a repository. In Oso, policies are written in Polar.

You can use Oso’s Rule Editor choose pre-built policies, or write a standalone policy.

Let’s create a simple policy that will allow John (a user) to read (an action) posts (a repository/resource).

Oso working principle diagram

In the Rules Editor, write the following Polar code.

allow("John", "read", "posts");

In the top-right corner, press Save to save the policy. You can also define tests for the policy like this:

Oso Rules Editor

test "john can read posts" {
    assert allow("John", "read", "posts");
    assert_not allow("John", "write", "posts");
}

Now, press Save and Run Tests in the top-right corner to save the policy and run the defined test. You should see “All tests passed!” if everything is good. Read more about testing policies in Oso.

Oso Rules Editor

Implementing the Oso Instance

In your Next.js project, you will create an authorization route in the Next.js API. Since Oso doesn’t support client-side execution, you will need to put it inside the API, which will make sure that sensitive information isn’t exposed to the end user.

In the src/pages/api folder, create a file authorize.ts. The authorize.ts will serve as a route handler to perform an authorization call to Oso, checking if the policy requirements are met.

Start by importing all necessary dependencies:

import type { NextApiRequest, NextApiResponse } from 'next';
import { Oso } from 'oso-cloud';
const API_KEY: string = process.env.OSO_AUTH || '';
const API_URL: string = 'https://cloud.osohq.com';

Next, you will create a handler function:

export default function handler(req: NextApiRequest, res: NextApiResponse)

Create an Oso instance inside the handler function and parse the query parameters sent from the client.

const oso = new Oso(API_URL, API_KEY);
const { userId, resource, action } = req.query;

To prevent stalled requests, you must wrap all response logic in a Promise. Afterward, you can perform an authorization call to Oso to check the query data against the defined policy.

return new Promise((resolve, reject) => {
    	oso
    		.authorize(String(userId), String(action), String(resource))
    		.then((result) => {
    			console.log(result);
    			res.status(200).json({ actionAllowed: result });
    		})
    		.catch((error) => {
    			console.log(error);
    			res.status(500).json({ actionAllowed: false });
    			resolve(error);
    		});
    	resolve(true);
    });

Implementing NextAuth

If you opt-in for using NextAuth, follow the next steps to implement a simple Credentials Provider.

Create a [..nextauth].ts file inside src/pages/api/auth folder. Start by importing necessary dependencies.

import CredentialsProvider from 'next-auth/providers/credentials';
import NextAuth from 'next-auth';
import type { NextAuthOptions } from 'next-auth';

Next, add authentication options which will include your preferred providers, callbacks and pages.

export const authOptions: NextAuthOptions = {
    // Configure one or more authentication providers
    providers: [
    	// ...add more providers here
    	CredentialsProvider({
    		// The name to display on the sign in form (e.g. "Sign in with...")
    		name: 'Credentials',
    		// `credentials` is used to generate a form on the sign in page.
    		// You can specify which fields should be submitted, by adding keys to the `credentials` object.
    		// e.g. domain, username, password, 2FA token, etc.
    		// You can pass any HTML attribute to the <input> tag through the object.
    		credentials: {
    			username: {
    				label: 'Username',
    				type: 'text',
    				placeholder: 'jsmith',
    			},
    			password: {
    				label: 'Password',
    				type: 'password',
    			},
    		},

Continue with implementing the authorize function which takes the credentials and the request as its parameters.

async authorize(credentials, req) {
    			const { username, password } = credentials as any;
    			const res = await fetch('http://localhost:8000/auth/login', {
    				method: 'POST',
    				headers: {
    					'Content-Type': 'application/json',
    				},
    				body: JSON.stringify({
    					username,
    					password,
    				}),
    			});

    			const user = await res.json();

    			console.log({ user });

    			if (res.ok && user) {
    				return user;
    			} else return null;
    		},
    	}),
    ],

Now, you will implement callbacks. Callbacks are asynchronous functions that control what happens when a specific action is performed.

callbacks: {
    	async jwt({ token, user }) {
    		return { ...token, ...user };
    	},
    	async session({ session, token, user }) {
    		// Send properties to the client, like an access_token from a provider.
    		session.user = token;

    		return session;
    	},
    },

Finally, add the pages object, containing the routes to authentication-specific pages.

NextAuth automatically creates simple, unbranded authentication pages for handling Sign in, Sign out, Email Verification and displaying error messages.

pages: {
    	signIn: '/auth/login',
},

At the end, you just have to export the NextAuth with the following:

export default NextAuth(authOptions);

That’s it. You have now successfully implemented NextAuth with a very simple credentials-based provider.

Implementing Authorization in Next.js with Oso

In this step, you will implement this authorization logic inside a Next.js page facing the client.

From the pages folder, open the index.tsx file (or create a new page that will be protected with authorization).

Start by importing the dependencies:

import { useState, useEffect } from 'react';
import { User } from '../types/interfaces';

You will now create a helper function canRead which will return a boolean indicating whether the user can perform a specific action (in this case, read).

async function canRead(user: User): Promise<boolean> {

First, the function call the API route you created previously.

    const response = await fetch(
    		`/api/authorize?userId=${user.name}&action=read&resource=posts`
    );

We will take the actionAllowed object from the response and return the boolean accordingly.

    const { actionAllowed } = await response.json();
    return actionAllowed;
}

Next, for the sake of simplicity, you will create a temporary user containing the name and role.

In a real example, you’d preferably take the user from the session.

const tempUser: User = {
    name: 'John',
    role: 'admin',
};

Inside the Home function (or the functional component of your page) add the data state hook.

const [data, setData] = useState<User | null>(null);

To update the page accordingly, use the useEffect hook.

useEffect(() => {
    	canRead(tempUser).then((readable) => readable && setData(tempUser));
}, []);
return <>{data?.name || 'denied'}</>;

That’s it. A simple logic that will display the user’s name if the user is authorized. Note that this shouldn’t be used in production - it’s just a simple explanation of how you could use Oso with Next.js.

You can find the full code example on GitHub.

Testing the Application

To test the application, make sure that the user defined in the Oso policy matches the one in the development environment.

In the terminal window, start the Next.js project by running npm run dev. This will start the server by default on http://localhost:3000.

ready - started server on 0.0.0.0:3000, url: http://localhost:3000
info  - Loaded env from /Users/oso/Projects/oso/oso-rbac-nextjs/.env
(node:5474) ExperimentalWarning: The Fetch API is an experimental feature. This feature could change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
event - compiled client and server successfully in 318 ms (170 modules)

Head to your browser and open the designated server URL (http://localhost:3000).

In case the user is authorized, you should see the name of the user shown on the screen. If the user is unauthorized, you will see denied.

Authorized:

Authorized Output

Unauthorized:

Unauthorized Output

Further Resources and Steps

As the complexity of the application increases, the need for a robust authorization system increases too. Oso is an Authorization-as-a-Service provider dedicated to simplifying authorization infrastructure and drastically reducing time-to-market.

To learn more about Oso and how to use it, refer to the official Oso Cloud Documentation.

Oso’s Authorization Academy is an excellent place to learn about authorization, Polar policies, RBAC, ReBAC (Relationship-Based Access Control), and more.

RBAC, as a policy access-control mechanism, is designed around roles and privileges. Implementing RBAC for granular control over resource access, simplified management of access control, and scalability in managing roles and permissions are some of the reasons to utilize RBAC.

To use this application in production, it is advised to use NextAuth, or a similar authentication provider.

Conclusion

In this article, you have learned how to implement Role-Based Access Control (RBAC) in a Next.js application using Oso - an authorization provider. Covering the project setup, type definitions, policy setup and logic handling inside the Next.js API.

Oso is used and trusted by major players like Visa, Verizon and Intercom that opt for AaaS providers in their complex infrastructures.