DEV Community

loading...

Unify ACL across front-end and back-end with Next.js and NextAuth.js

noclat profile image Nicolas Torres Updated on ・7 min read

Access Control List (ACL) is a security layer to restrict access to resources based on the user's or client's assigned role. While it's being widely used to protect APIs, it can be convenient to also hide some features on the interface from unauthorized users. Instead of duplicating the permissions definition and logic, we'll see how we can seamlessly combine and share them across the application front-end and back-end with Next.js.

Note that implementing ACL on the front-end only is not enough of a security. An advised user could inject the permissions and reveal any protected feature you're trying to hide from them. That's why it's important to protect both ends of the application.

Before we start

In this article, I'll use the AccessControl package to implement ACL because I like its structure, performances, and simple usage, but the example can be replicated with other ACL packages as well.

I also heavily rely on next-connect to chain middlewares. Again, it's not mandatory, but it makes the API handlers less verbose and therefore shorten the examples.

The folder structure I've set is completely arbitrary apart from pages which is the entry point in Next.js. I share it there in order to clarify the examples:

+-- public/                 // Static assets
+-- src/                    // Whole application source code
|   +-- app/                // Front-end utilities
|   |   +-- components/     // Base resuable components
|   |   +-- features/       // Custom hooks, helpers, contexts
|   |   +-- layouts/        // App specific components
|   +-- pages/              // Entry point for Pages
|   |   +-- api/            // Entry point for API routes
|   +-- server/             // Back-end utilities
|   |   +-- helpers/        // Helper functions
|   |   +-- middlewares/    // API middleware functions
|   |   +-- services/       // Third-party services instances
Enter fullscreen mode Exit fullscreen mode

1. Defining the permissions

AccessControl stores permissions into memory. We'll define permissions in a module so they're centralized, and loaded any time we require the module for the first time after the process starts.

// src/server/services/accesscontrol.js
// ----------------------------------------
import AccessControl from 'accesscontrol';

// unique back-end instance of AccessControl
export const ac = new AccessControl();

// owners can manage all users
ac.grant('owner').resource('users').readAny().createAny().updateAny().deleteAny();

// guests can manage only their own profile
ac.grant('guest').resource('users').readOwn().updateOwn();

// we lock ACL to avoid updates out of this file
ac.lock();

// src/server/services/index.js
// ----------------------------------------
export { ac } from './accesscontrol';
Enter fullscreen mode Exit fullscreen mode

2. Protecting API routes

The basic way to check a permission is running:

const permission = ac.can('guest').readOwn('users');
console.log(permission.granted); // -> true

// or
const permission = ac.permission({
  role: 'guest',
  resource: 'users',
  action: 'read:own',
)};
console.log(permission.granted); // -> true
Enter fullscreen mode Exit fullscreen mode

But having to pass the role each time we ask for a permission is a bit verbose and unnecessary. It's up to us to implement a middleware the way we want it, and I usually store the user's role in session. Let's create a checkAccess middleware that will read the role from the session so there's no need to specify it at each call.

// src/server/middlewares/checkAccess.js
// ----------------------------------------
import { ac } from 'server/services'; // load ACL

export const checkAccess = (resource, action, possession) => (req, res, next) => {
  let permission;
  try {
    permission = ac.permission({
      role: req.session?.user?.role, // we'll see later about that
      resource,
      action,
      possession,
    });
  } catch { // `ac.permission` throws if role is not a string
    permission = { granted: false };
  }

  // return 403 if access is denied
  if (!permission.granted) {
    return res.status(403).json({
      ok: false,
      message: 'You are not authorized to access this resource',
    });
  }

  return next();
};

// src/server/middlewares/userAuth.js
// ----------------------------------------
import { getSession } from 'next-auth/client';

// check if user is authenticated
export const userAuth = async (req, res, next) => {
  // store session into request to pass it to following middlewares
  req.session = await getSession({ req });
  if (!req.session) {
    return res.status(403).send({
      ok: false,
      message: `User authentication required.`,
    });
  }
  return next();
};

// src/server/middlewares/index.js
// ----------------------------------------
export { checkAccess } from './checkAccess';
export { userAuth } from './userAuth';
Enter fullscreen mode Exit fullscreen mode

Then we can simply chain it before our final API route handler to protect its access:

// src/page/api/users/[id].js
// ----------------------------------------
import nc from 'next-connect';
import { checkAccess, userAuth } from 'server/middlewares';

const handler = nc();
  .use(userAuth) // injects session into req.session
  .use(checkAccess('users', 'read:own'))
  .use(checkAccess('profile', 'read:own')) // you can chain multiple checks
  .get((req, res) => {
    // get user profile from DB
    return res.send({
      ok: true,
      data: user,
    });
  });

export default handler;
Enter fullscreen mode Exit fullscreen mode

What about specific permissions per method?

// src/page/api/users/[id].js
// ----------------------------------------
import nc from 'next-connect';
import { checkAccess, userAuth } from 'server/middlewares';

const handler = nc();
  .use(userAuth) // injects session into req.session
  .get(checkAccess('users', 'read:own'), (req, res) => {
    // get user profile from DB
    return res.send({
      ok: true,
      data: user,
    });
  })
  .put(checkAccess('users', 'update:own'), (req, res) => {
    // update user profile in DB
    return res.send({
      ok: true,
      data: user,
    });
  })
  .delete(checkAccess('users', 'delete:any'), (req, res) => {
    // delete user profile from DB
    return res.send({
      ok: true,
      data: {},
    });
  });

export default handler;
Enter fullscreen mode Exit fullscreen mode

3. Adding user role to the session

NextAuth makes it a breeze to handle user authentication with both email and social accounts. Though we'll need to extend it a little bit to add a role column into the users database table it creates, and then inject the role into the session. Following their documentation, we end up doing:

// src/page/api/[...nextauth].js
// ----------------------------------------
import NextAuth from 'next-auth';
import Adapters from 'next-auth/adapters';
import Providers from 'next-auth/providers';

const options = {
  site: process.env.NEXTAUTH_URL,
  database: process.env.DATABASE_URL,
  providers: [/* ... */],
  // alter user schema to add a `role` column
  adapter: Adapters.TypeORM.Adapter(
    process.env.DATABASE_URL,
    {
      models: {
        User: {
          model: Adapters.TypeORM.Models.User.model,
          schema: {
            ...Adapters.TypeORM.Models.User.schema,
            columns: {
              ...Adapters.TypeORM.Models.User.schema.columns,
              role: {
                type: 'varchar',
                nullable: true,
              },
            }
          }
        },
      },
    }
  ),
  // assign default role on user creation
  events: {
    createUser: async (user) => {
      // use your database package to safely perform the following query:
      await db.run(
        'UPDATE users SET role="guest" WHERE id=${id}',
        { id: user.id }
      );
    },
  },
  // enrich session data
  callbacks: {
    session: async (session, user) => {
      session.user.role = user.role; // inject role into session
      return Promise.resolve(session);
    },
  },
};

export default (req, res) => NextAuth(req, res, options);
Enter fullscreen mode Exit fullscreen mode

4. Sharing permissions with the front-end

To check permissions on the front-end, we could technically just require src/server/services/accesscontrol.js and perform the same checks using ac.can('guest').readOwn('users').granted for example. But as we're in the browser and already using React, better spare as much memory as we can and not load the full list of permissions, that can be very long in large applications.

That's why the strategy here is to pass only the permissions tied to the user's role, and for this, we'll again rely on the session object. Let's update our session callback in NextAuth options:

// src/page/api/[...nextauth].js
// ----------------------------------------
// ...
import { ac } from 'server/services';
// ...
const options = {
  // ...
  // enrich session data
  callbacks: {
    session: async (session, user) => {
      session.user.role = user.role;
      // get all permissions
      const grants = ac.getGrants();
      // expose only the current role permissions
      session.user.permissions = user.role in grants ?
        { [user.role]: grants[user.role] } :
        {};
      return Promise.resolve(session);
    },
  },
};
// ...
Enter fullscreen mode Exit fullscreen mode

We'll then create an independent and volatile instance of AccessControl in the front-end, and store the permissions into a React context to check access from any component.

4. Storing permissions into a context

Again here, to avoid passing the role as argument each time to AccessControl, we'll expose a nice wrapper from the React context we'll create for accessing user's data:

// src/app/features/UserContext.jsx
// ----------------------------------------
import AccessControl from 'accesscontrol';
import { useSession } from 'next-auth/client';
import React, { createContext, useCallback, useContext, useEffect } from 'react';

// front-end isolated instance of AccessControl
const ac = new AccessControl();

// create and export User context
export const UserContext = createContext({
  access: () => ({ granted: false }),
  isLoading: false,
  user: {},
});

// export hook context wrapper
export const useUserContext = () => useContext(UserContext);

// export pre-configured provider
export const UserProvider = (props) => {
  const [session, isLoading] = useSession();

  // update permissions from session data
  const permissions = session?.user?.permissions || {};
  useEffect(
    () => {
      ac.setGrants(permissions);
      return () => {
        ac.reset(); // reset when permissions change
      };
    },
    [permissions]
  );

  // expose access checking wrapper method
  // automatically fill user role on permission check
  const role = session?.user?.role || 'guest';
  const access = useCallback(
    (resource, action, possession) => {
      try {
        return ac.permission({
          role,
          resource,
          action,
          possession,
        });
      } catch { // if role is not a string
        return { granted: false };
      }
    },
    [role]
  );

  // Return context values
  // ----------------------------------------
  const context = {
    access,
    isLoading,
    user: session?.user,
  };

  return (
    <UserContext.Provider value={context}>
      {props.children}
    </UserContext.Provider>
  );
};

// src/app/features/index.js
// ----------------------------------------
export { UserContext, useUserContext } from './UserContext';
Enter fullscreen mode Exit fullscreen mode

Then we only need to wrap our application with the User context inside src/pages/_app.js:

// src/pages/_app.js
// ----------------------------------------
import React from 'react';

import { UserProvider } from 'app/features';

const App = ({ Component, pageProps }) => (
  <UserProvider>
    <Component {...pageProps} />
  </UserProvider>
);

export default App;
Enter fullscreen mode Exit fullscreen mode

5. Hiding features from unauthorized users

The setup is now complete, we have protected our API routes and the users, even if they hack their way to access the features from the interface, they won't be able to perform any action on the API as long as your permissions are defined accordingly.

So, hiding content on the interface becomes a matter of seconds using our User context, for example to filter the list of accessible links in the main navigation:

// src/app/layouts/MainNav.jsx
// ----------------------------------------
import Link from 'next/link';
import { useUserContext } from 'app/features';

const MainNav = (props) => {
  const router = useRouter();
  const { access } = useUserContext();

  return (
    <ul>
      <li>
        <Link href="/">
          <a>Dashboard</a>
        </Link>
      </li>
      {access('users', 'read:any').granted && (
        <li>
          <Link href="/users">
            <a>Manage users</a>
          </Link>
        </li>
      )}
      {access('users', 'read:own').granted && (
        <li>
          <Link href="/profile">
            <a>Profile</a>
          </Link>
        </li>
      )}
    </ul>
  );
};
Enter fullscreen mode Exit fullscreen mode

The code examples may seem quite long, but we've basically just wrapped some AccessControl features to provide a better developer experience within our stack, ending up with a unified and performant solution to protect both front-end and back-end features using a single ACL declaration. Once the setup complete, the usage is a matter of adding a single line of code here and there.

If you're not using any of the packages here, it's still quite easy to replicate the strategy with vanilla Next.js and whichever session manager you use. I personally also assign a role to any API client (the front-end is a client too) based on the Bearer Token they're using (into req.role directly, overriden when there's a user session with role: req.session?.user?.role || req.role in checkAccess middleware). That way I can open some API routes for external user-less usage and limit their scope with the same ACL layer.

Discussion (0)

pic
Editor guide