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:
"use client";
export * from "@dynamic-labs/sdk-react-core";
export * from "@dynamic-labs/ethereum";
Secondly, we’ll create a wrapper for the provider itself:
"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:
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>
);
}
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:
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:
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 {
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:
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:
export const getKey = (
headers,
callback: (err: Error | null, key?: Secret) => void
): void => {
console.log("calling getKey");
const options = {
method: "GET",
headers: {
Authorization: `Bearer ${process.env.NEXT_DYNAMIC_BEARER_TOKEN}`,
},
};
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);
})
.catch((err) => {
console.error(err);
callback(err);
});
};
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:
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;
};
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;
if (typeof token !== "string" || !token) {
throw new Error("Token is required");
}
const jwtPayload = await validateJWT(token);
if (jwtPayload) {
const user: User = {
id: jwtPayload.sub,
name: jwtPayload.name || "",
email: jwtPayload.email || "",
};
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:
...
<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);
} else {
console.error("Failed to log in");
}
})
.catch((error) => {
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:
...
async authorize(
credentials: Partial<Record<"token", unknown>>,
request: Request
): Promise<User | null> {
const token = credentials.token as string;
if (typeof token !== "string" || !token) {
throw new Error("Token is required");
}
const jwtPayload = await validateJWT(token);
if (jwtPayload) {
const user: User = {
id: jwtPayload.sub,
name: jwtPayload.name || "",
email: jwtPayload.email || "",
scopes: jwtPayload.scopes || [],
};
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
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.