DEV Community

Cover image for Streamlining Role-Based Access Control in Next.js with Descope and Auth.js: A Step-by-Step Guide
Tal Moskovich
Tal Moskovich

Posted on

Streamlining Role-Based Access Control in Next.js with Descope and Auth.js: A Step-by-Step Guide

I've recently discovered Descope, a new auth provider service with a great developer experience. They aim to greatly simplify authentication and authorization implementation for developers. They have a generous free plan for 7,500 MAUs, which convinced me to try them out on my Next.js app.

Let's learn a bit about Descope and how to use it with Auth.js (next-auth) to protect our Next.js app with role-based access control (RBAC).

Hello Descope

Descope is a developer-friendly authentication and user management platform that improves account security and UX by focusing on passwordless authentication. It supports a variety of methods like magic links, biometrics, and social logins for more secure, accurate, and user-friendly authentication.

In addition to enhancing UX, Descope also improves DX by providing a drag-and-drop visual interface to create and customize login flows for your app. The workflows (called Descope Flows) can be embedded in an app with a few lines of code. Additionally, you don’t need to touch your codebase while making changes to your login process – just change the Descope Flow and your app is updated in real time.

What I Liked About Descope

  • Onboarding to a new project is seamless - You can easily set up the whole process with just a few clicks, including choosing various auth methods. The flows are created for you automatically.

  • You can control every single piece of the auth lifecycle, with a clever and convenient workflow editor

  • A lot of things that may be cumbersome with other auth providers, are solved with one click in Descope. The best example of this is unifying accounts across multiple auth methods by email, which in some other platforms requires writing custom code.

Starting a New Descope Project

In this sample, I've followed these guidelines:

  • Set my application as Consumers Application

  • Chose Social Login and Magic Link as the main authentication methods

  • Chose One Time Password as MFA method

    After the onboarding process:

  • Added Google as social login, and made sure I turned on Merge user accounts based on email address

  • Created an Access Key to use in our Next.js app

  • The Project ID can be found under the main IDP application in the Flow Hosting URL

Setting up Descope With Our Next.js App

Following the Descope quick start guide is the best way to setting up our Next.js app:

First, install Auth.js with: npm i next-auth

Next, create the main endpoint for the auth process. Auth.js will take over all the sub-routes. In this endpoint, we set Auth.js to take over from Descope as an auth provider. The file is app/api/auth/[...nextauth]/route.ts :

import NextAuth from "next-auth/next";
import { NextAuthOptions } from "next-auth"


export const authOptions: NextAuthOptions = {
    providers: [
    {
        id: "descope",
        name: "Descope",
        type: "oauth",
        wellKnown: `https://api.descope.com/<Descope Project ID>/.well-known/openid-configuration`,
        authorization: { params: { scope: "openid email profile" } },
        idToken: true,
        clientId: "<Descope Project ID>",
        clientSecret: "<Descope Access Key>",
        checks: ["pkce", "state"],
        profile(profile) {
            return {
                id: profile.sub,
                name: profile.name,
                email: profile.email,
                image: profile.picture,
            }
        },
    }]
}  


const handler = NextAuth(authOptions)
export { handler as GET, handler as POST }
Enter fullscreen mode Exit fullscreen mode

For the client side, the access to the session and the logged-in user data is provided through the Auth Provider. The way to do this is to create a client component that exports the default Auth.js Auth Provider. That way, other client components wrapped with this provider will have access to the useSession hook.

Next up, create the AuthProvider client component with this code...

'use client'

import { SessionProvider } from "next-auth/react"


export default function NextAuthSessionProvider(
    { children }: 
    { children: React.ReactNode }
) {
    return (
        <SessionProvider>
            { children }
        </SessionProvider>
    )
}
Enter fullscreen mode Exit fullscreen mode

...and wrap up the body in the root layout.tsx file with the new provider:

import NextAuthSessionProvider from './provider'


export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        <NextAuthSessionProvider>
          <div>
            {children}
          </div>
        </NextAuthSessionProvider>
      </body>
    </html>
  )
}
Enter fullscreen mode Exit fullscreen mode

Sign In & Sign Out

The Sign In & Sign Out processes will be initiated by a button. These buttons should live in a client context, therefore their wrapper should be tagged as use client.

My wrapper component is the Navbar. There, I can get the session with the useSession hook, check the auth status (authenticated/unauthenticated), and present the right button. Let's see it in action:

"use client";
import { signIn, signOut, useSession } from "next-auth/react";

const Navbar = () => {
  const session = useSession();
  return (
    //...
          {session.status === "unauthenticated" && (
            <li>
              <button className="text-white" onClick={() => signIn("descope")}>
                Log In
              </button>
            </li>
          )}
          {session.status === "authenticated" && (
            <li>
              <button className="text-white" onClick={() => signOut()}>
                Log Out
              </button>
            </li>
          )}
        //...
  );
};

export default Navbar;
Enter fullscreen mode Exit fullscreen mode

Role-Based Access Control (RBAC)

Descope supports three levels of RBAC: Tenant, Role, and Permission. Tenant is mainly for separating users' authorizations to one or more sub-platforms on your applications (for example, for different clients or customers), mainly for B2B apps or services. In our example we won't use tenancy, as we're building a B2C app.

You can quickly attach these access control properties to a user with Descope's SDK or through the console, see Tenants and Authorization in the main navbar, and after that pick a user through Users, and attach a role to the user.

Roles (and tenants in B2B) are part of the claims data we get in the profile from Descope. You can attach to the next-auth session even more details, and even add custom claims as a part of Descope's flow.

Let's see how to get the user's role on our Auth.js session. We need to modify a few things in the [...nextauth]/route.ts file.

  • Add descope.claims as a scope param, under the provider authorization:

     params: {
            //Add descope.claims
              scope: "openid email profile descope.claims",
            },
    
  • The profile function under the provider settings serves as a tunnel between the OAuth profile (in our case, Descope), and the next-auth's User structure. In our case, we need to get the roles from descope.claims:

    profile(profile) {
            return {
            ...
              roles: profile.roles, //Add this line
            };
          },
    
  • Right after the providers' array, add a callbacks object, and write two simple callback functions.

    • jwt - Here we can add data to the token. This callback is called on every JWT creation or update (i.e. whenever a session is accessed in the client). The return value will be encrypted and saved as a cookie. In our case, we will burn the roles on the JWT.
    • session - The session callback is called whenever a session is checked. Here we can add more data to our session, and we can take the roles from the token into the session's user data.

    Additional documentation about the callbacks can be found here.

 providers: [
    {
     //Descope Provider
    },
  ],
  callbacks: {
    async jwt({ token, profile }) {
      if (profile) {
        token.roles = profile.roles;
      }
      return token;
    },
    async session({ session, token }) {
      if (session.user) session.user.roles = token.roles;

      return session;
    },
  },
Enter fullscreen mode Exit fullscreen mode

As a tip, you can add a nextauth.d.ts file to your codebase to include the new attributes. Typescript will thank you for that :)

import "next-auth";
import "next-auth/jwt";

type AuthRole = "Admin" | "Client";

declare module "next-auth" {
  interface User {
    roles: AuthRole[];
  }

  interface Session {
    user: User;
  }

  interface Profile {
    roles: AuthRole[];
  }
}

declare module "next-auth/jwt" {
  interface JWT {
    roles: AuthRole[];
  }
}
Enter fullscreen mode Exit fullscreen mode

Client-Side & Server-Side Authorization

Now you can protect your app, and make sure that the pages/routes will be accessible just to the right user with the right authorization.

  • On the client side (client components), use the useSession hook, and see if session.state === "authenticated" and if session.user.roles includes the right role. See the docs. See the docs for more details.

  • You can similarly use the getServerSession function on the server side (server components and routes). See the docs for more details.

  • You can even maintain a middleware for stricter maintenance. In the middleware, just the JWT is exposed and not the whole session. See the docs for more details.

You can see my implementation in the demo app, auth branch.

Descope's Connectors

What I like in Descope's experience is the seamless integration with 3rd parties, via the Connectors. These services could be Hubspot, Twillio, Datadog, and others.

Without even a line of code, you can use a wide variety of connectors in your flows. Here's a simple example of adding reCAPTCHA to the sign up or in flow:

Give It a Try!

I tried here to demonstrate the DX of using Descope as an auth provider in a Next.js app. I was impressed by the smooth experience, as well the unique security functionalities they’ve added to take the burden off developer shoulders.

My demo app is available on GitHub. Make sure you have 2 branches there: main for the app without auth processes, and auth with Descope & Auth.js as RBAC auth is demonstrated there.

Top comments (1)

Collapse
 
yoavsbg17 profile image
Yoav Sabag

Great post!