Streamlining Role-Based Access Control in Next.js with Descope and Auth.js: A Step-by-Step Guide
Tags: webdev, authjs, nextjs, descope
Published: 2024-02-03 17:23:10

I've recently discovered [Descope](https://www.descope.com/), 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](https://authjs.dev/) (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](https://www.descope.com/learn/post/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](https://www.descope.com/flows) 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.
    
{% embed https://www.youtube.com/watch?v=HB_STzpwcZo %}

### 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`
    
{% embed https://www.youtube.com/watch?v=J3a537YDnXQ %}

### Setting up Descope With Our Next.js App

Following the Descope [quick start guide](https://docs.descope.com/build/guides/gettingstarted/) 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` :

```typescript
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 }
```

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...

```typescript
'use client'

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


export default function NextAuthSessionProvider(
    { children }: 
    { children: React.ReactNode }
) {
    return (
        <SessionProvider>
            { children }
        </SessionProvider>
    )
}
```

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

```typescript
import NextAuthSessionProvider from './provider'


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

### 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:

```typescript
"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;
```

### Role-Based Access Control (RBAC)

Descope supports three levels of [RBAC](https://docs.descope.com/manage/roles/): 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](https://docs.descope.com/manage/customclaims/) 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:
    
    ```typescript
     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`:
    
    ```typescript
    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](https://next-auth.js.org/configuration/callbacks).
    

```typescript
 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;
    },
  },
```

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 :)

```typescript
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[];
  }
}
```

### 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](https://next-auth.js.org/getting-started/client) for more details.
    
* You can similarly use the `getServerSession` function on the server side (server components and routes). See the [docs](https://next-auth.js.org/configuration/nextjs) 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](https://next-auth.js.org/configuration/nextjs) for more details.
    

You can see my implementation in the [demo app](https://github.com/talmosko/ecommerce-nextjs-descope/tree/auth), `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](https://www.descope.com/integrations) in your flows. Here's a simple example of adding reCAPTCHA to the `sign up or in` flow:

{% embed https://youtu.be/oYniSg7yJq0 %}

### 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](https://github.com/talmosko/ecommerce-nextjs-descope). 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.