Pre-requisites

Steps

Add the right env variables

You’ll need to define two environment variables in your .env.local file:

NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID=
NEXT_DYNAMIC_BEARER_TOKEN=

You’ll be able to find both in the SDK & API Keys page of the Dynamic dashboard. The first will already be generated for you but for the API key, you’ll need to generate your own via the UI on that page.

Make sure you add the values of each variable to the .env.local file, and you’re good to go.

Add the Dynamic Provider

We are going to use the Dynamic UI on the client, so the first thing we’ll want to do is add an initialize the DynamicContextProvider and then we can use the DynamicWidget UI.

First make sure you have the appropriate dependencies installed:

npm install @dynamic-labs/sdk-react-core @dynamic-labs/ethereum

Next we will add the provider. Normally you would add the provider to one of your high level component files directly, wrapping everything else but in Next, this will likely cause some client - server issues especially because we’re going to be utalizing callbacks, so we’ll handle this in two ways:

Firstly, we’ll create a file to export what we need from Dynamic and then we’ll import that file in the client side code:

// app/lib/dynamic.ts

"use client";

export * from "@dynamic-labs/sdk-react-core";
export * from "@dynamic-labs/ethereum";

Secondly, we’ll create a wrapper for the provider itself:

// app/components/dynamic-wrapper.ts

"use client";

import { DynamicContextProvider } from "../lib/dynamic";
import { EthereumWalletConnectors } from "../lib/dynamic";

export default function ProviderWrapper({ children }: React.PropsWithChildren) {
  return (
    <DynamicContextProvider
      settings={{
        environmentId: process.env.NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID,
        walletConnectors: [EthereumWalletConnectors],
      }}
    >
      {children}
    </DynamicContextProvider>
  );
}

Note that we are using only EthereumWalletConnectors, but you can add any of the other connectors you want to use.

Now we can add the wrapper to our layout.tsx file:

// app/layout.tsx

import "./globals.css";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import Footer from "@/components/footer";
import Header from "@/components/header";
import ProviderWrapper from "@/components/dynamic-wrapper";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "NextAuth.js Example",
  description:
    "This is an example site to demonstrate how to use NextAuth.js for authentication",
};

export default function RootLayout({ children }: React.PropsWithChildren) {
  return (
    <html lang="en">
      <ProviderWrapper>
        <body className={inter.className}>
          <div className="flex flex-col justify-between w-full h-full min-h-screen">
            <Header />
            <main className="flex-auto w-full max-w-3xl px-4 py-4 mx-auto sm:px-6 md:py-6">
              {children}
            </main>
            <Footer />
          </div>
        </body>
      </ProviderWrapper>
    </html>
  );
}

Add the DynamicWidget

You don’t need to add the widget at the same level as the provider, you can place this anywhere. For this example we’ll add it to the header:

// app/components/header.tsx

import { MainNav } from "./main-nav";
import UserButton from "./user-button";
import { DynamicWidget } from "../lib/dynamic";

export default function Header() {
  return (
    <header className="sticky flex justify-center border-b">
      <div className="flex items-center justify-between w-full h-16 max-w-3xl px-4 mx-auto sm:px-6">
        <MainNav />
        <DynamicWidget />
        <UserButton />
      </div>
    </header>
  );
}

Now we’re almost done with the client side. We’ll need to somehow send the JWT that Dynamic returns on login to our server functions so that we can validate it and create a session. To do this we’ll use the events which Dynamic provides but let’s come back to that and first add the server side code.

Define the JWT decoding

NextAuth needs to know how to decode and validate the JWT which Dynamic sends back. To do this we’ll create a custom JWT decoder inside a new helper file:

// app/lib/authHelpers.ts

import jwt, { JwtPayload, Secret, VerifyErrors } from "jsonwebtoken";

export const validateJWT = async (
  token: string
): Promise<JwtPayload | null> => {
  try {
    const decodedToken =
      ((await new Promise()) < JwtPayload) |
      (null >
        ((resolve, reject) => {
          jwt.verify(
            token,
            getKey,
            { algorithms: ["RS256"] },
            (
              err: VerifyErrors | null,
              decoded: string | JwtPayload | undefined
            ) => {
              console.log("decoded the jwt");
              if (err) {
                reject(err);
              } else {
                // Ensure that the decoded token is of type JwtPayload
                if (typeof decoded === "object" && decoded !== null) {
                  resolve(decoded);
                } else {
                  reject(new Error("Invalid token"));
                }
              }
            }
          );
        }));
    return decodedToken;
  } catch (error) {
    console.error("Invalid token:", error);
    return null;
  }
};

You’ll see that the above function depends on a few things, one of which is the external jsonwebtoken library. We’ll need to install this:

npm install jsonwebtoken

Next we’ll need to define the getKey function which is used to fetch the public key which you can use to decode the JWT. This function will make an API call to Dynamic. We’ll add this to the same file:

// app/lib/authHelpers.ts

export const getKey = (
  headers,
  callback: (err: Error | null, key?: Secret) => void
): void => {
  console.log("calling getKey");

  // Define the options for the fetch request
  const options = {
    method: "GET",
    headers: {
      Authorization: `Bearer ${process.env.NEXT_DYNAMIC_BEARER_TOKEN}`,
    },
  };

  // Perform the fetch request asynchronously
  fetch(
    `https://app.dynamicauth.com/api/v0/environments/${process.env.NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID}/keys`,
    options
  )
    .then((response) => {
      return response.json();
    })
    .then((json) => {
      const publicKey = json.key.publicKey;
      const pemPublicKey = Buffer.from(publicKey, "base64").toString("ascii");
      callback(null, pemPublicKey); // Pass the public key to the callback
    })
    .catch((err) => {
      console.error(err);
      callback(err); // Pass the error to the callback
    });
};

With this in place, there are just two steps left. Firstly we’ll need to adapt the NextAuth configuration to use the CredentialsProvider so the JWT works, and then we’ll need to trigger everything correct when a user logs in.

Update the NextAuth configuration

You can copy the below code and paste it over the full existing auth.ts file in the demo repo:

// ./auth.ts

import NextAuth from "next-auth";

import type { NextAuthConfig } from "next-auth";

import Credentials from "@auth/core/providers/credentials";
import { validateJWT } from "./authHelpers";

type User = {
  id: string;
  name: string;
  email: string;
  // Add other fields as needed
};

export const config = {
  theme: {
    logo: "https://next-auth.js.org/img/logo/logo-sm.png",
  },
  providers: [
    Credentials({
      name: "Credentials",
      credentials: {
        token: { label: "Token", type: "text" },
      },
      async authorize(
        credentials: Partial<Record<"token", unknown>>,
        request: Request
      ): Promise<User | null> {
        const token = credentials.token as string; // Safely cast to string; ensure to handle undefined case
        if (typeof token !== "string" || !token) {
          throw new Error("Token is required");
        }
        const jwtPayload = await validateJWT(token);

        if (jwtPayload) {
          // Transform the JWT payload into your user object
          const user: User = {
            id: jwtPayload.sub, // Assuming 'sub' is the user ID
            name: jwtPayload.name || "", // Replace with actual field from JWT payload
            email: jwtPayload.email || "", // Replace with actual field from JWT payload
            // Map other fields as needed
          };
          return user;
        } else {
          return null;
        }
      },
    }),
  ],
  callbacks: {
    authorized({ request, auth }) {
      const { pathname } = request.nextUrl;
      if (pathname === "/middleware-example") return !!auth;
      return true;
    },
  },
} satisfies NextAuthConfig;

export const { handlers, auth, signIn, signOut } = NextAuth(config);

Trigger the JWT validation on login

We’ll need to access the JWT which Dynamic sends back after a user has logged in so that we can co-ordinate with NextAuth. To do this we’ll use one of the events which Dynamic provides. Back in the dynamic-wrapper.ts file we’ll adjust the settings object passed to DynamicContextProvider as a prop to the following:

// app/components/dynamic-wrapper.ts

...

<DynamicContextProvider
   settings={{
        environmentId: process.env.NEXT_PUBLIC_DYNAMIC_ENVIRONMENT_ID,
        walletConnectors: [EthereumWalletConnectors],
        events: {
          onAuthSuccess: async (event) => {
            const authToken = getAuthToken();

            const csrfToken = await getCsrfToken();

            fetch("/api/auth/callback/credentials", {
              method: "POST",
              headers: {
                "Content-Type": "application/x-www-form-urlencoded",
              },
              body: `csrfToken=${encodeURIComponent(
                csrfToken
              )}&token=${encodeURIComponent(authToken)}`,
            })
              .then((res) => {
                if (res.ok) {
                  console.log('LOGGED IN', res);
                  // Handle success - maybe redirect to the home page or user dashboard
                } else {
                  // Handle any errors - maybe show an error message to the user
                  console.error("Failed to log in");
                }
              })
              .catch((error) => {
                // Handle any exceptions
                console.error("Error logging in", error);
              });
          },
        },
      }}
>
...

Note that we’re using the getCsrfToken function which NextAuth provides. This is important because NextAuth uses CSRF tokens to prevent CSRF attacks.

Token Scopes

A JWT token supports the concept of scopes, which are used to define the permissions that the token has. Dynamic uses various scopes to identify limitations for a token.

The most common and important scope value is requiresAdditionalAuth which signifies that the token requires MFA to be completed before the token is considered valid and the user is fully authenticated.

Our SDK handles this for the frontend, but for the backend you will need to check this scope and handle it accordingly.

To do this, you can add a check for the requiresAdditionalAuth scope in the authorize function in the auth.ts file:

// ./auth.ts
...
    async authorize(
        credentials: Partial<Record<"token", unknown>>,
        request: Request
      ): Promise<User | null> {
        const token = credentials.token as string; // Safely cast to string; ensure to handle undefined case
        if (typeof token !== "string" || !token) {
          throw new Error("Token is required");
        }
        const jwtPayload = await validateJWT(token);

        if (jwtPayload) {
          
          // Transform the JWT payload into your user object
          const user: User = {
            id: jwtPayload.sub, // Assuming 'sub' is the user ID
            name: jwtPayload.name || "", // Replace with actual field from JWT payload
            email: jwtPayload.email || "", // Replace with actual field from JWT payload
            scopes: jwtPayload.scopes || [], // Add the scopes to the user object
            // Map other fields as needed
          };
          return user;
        } else {
          return null;
        }
      }
...

Alternatively, you can return null if you do not want to handle the token with the requiresAdditionalAuth scope.

Run the example

npm run dev

You should now see a “Connect your wallet” button in the header, which you can use to log in with a wallet etc. Once you’ve logged in you should see “LOGGED IN” in the browser console.

Going further

You’ll see in auth.js that we are assigning certain JWT fields to a user object. You can add any fields you want to this object, and then access them in your pages via the useSession hook.