🛡️NextAuth.js
A complete open source authentication solution.
NextAuth.js is an easy-to-implement, full-stack (client/server) open-source authentication library originally designed for Next.js and serverless applications.
The library provides the ability to set up a custom credential provider, which we can take advantage of in order to authenticate users using their existing Ethereum wallet via Sign-In with Ethereum (EIP-4361).
The complete example can be found here.
Getting started
First clone the official NextAuth.js example using your terminal:
git clone https://github.com/nextauthjs/next-auth-example
Then, switch to the project directory:
cd next-auth-example
After cloning, modify the given
.env.local.example
file, and populate it with the following variables:
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=somereallysecretsecret
Note: After this, rename the file to .env.local
. This example will be routed to http://localhost:3000.
Next Add
siwe
,ethers
, andwagmi
as dependencies. In this example, we're using wagmi, which is a well-known React hooks library for Ethereum. In your terminal, navigate to the project we originally cloned and add the dependencies via the following commands:
yarn add siwe@beta ethers wagmi
Now, modify
pages/_app.tsx
to inject theWagmiProvider
component:
import { Session } from "next-auth"
import { SessionProvider } from "next-auth/react"
import type { AppProps } from "next/app"
import { WagmiConfig, createClient, configureChains, chain } from "wagmi"
import { publicProvider } from "wagmi/providers/public"
import "./styles.css"
export const { chains, provider } = configureChains(
[chain.mainnet, chain.polygon, chain.optimism, chain.arbitrum],
[publicProvider()]
)
const client = createClient({
autoConnect: true,
provider,
})
// Use of the <SessionProvider> is mandatory to allow components that call
// `useSession()` anywhere in your application to access the `session` object.
export default function App({
Component,
pageProps,
}: AppProps<{
session: Session;
}>) {
return (
<WagmiConfig client={client}>
<SessionProvider session={pageProps.session} refetchInterval={0}>
<Component {...pageProps} />
</SessionProvider>
</WagmiConfig>
)
}
We're going to now add the provider that will handle the message validation. Since it's not possible to sign in using the default page, the original provider should be removed from the list of providers before rendering. Modify
pages/api/auth/[...nextauth].ts
with the following:
import NextAuth from "next-auth"
import CredentialsProvider from "next-auth/providers/credentials"
import { getCsrfToken } from "next-auth/react"
import { SiweMessage } from "siwe"
// For more information on each option (and a full list of options) go to
// https://next-auth.js.org/configuration/options
export default async function auth(req: any, res: any) {
const providers = [
CredentialsProvider({
name: "Ethereum",
credentials: {
message: {
label: "Message",
type: "text",
placeholder: "0x0",
},
signature: {
label: "Signature",
type: "text",
placeholder: "0x0",
},
},
async authorize(credentials) {
try {
const siwe = new SiweMessage(JSON.parse(credentials?.message || "{}"))
const nextAuthUrl = new URL(process.env.NEXTAUTH_URL)
const result = await siwe.verify({
signature: credentials?.signature || "",
domain: nextAuthUrl.host,
nonce: await getCsrfToken({ req }),
})
if (result.success) {
return {
id: siwe.address,
}
}
return null
} catch (e) {
return null
}
},
}),
]
const isDefaultSigninPage =
req.method === "GET" && req.query.nextauth.includes("signin")
// Hide Sign-In with Ethereum from default sign page
if (isDefaultSigninPage) {
providers.pop()
}
return await NextAuth(req, res, {
// https://next-auth.js.org/configuration/providers/oauth
providers,
session: {
strategy: "jwt",
},
secret: process.env.NEXTAUTH_SECRET,
callbacks: {
async session({ session, token }: { session: any; token: any }) {
session.address = token.sub
session.user.name = token.sub
session.user.image = "https://www.fillmurray.com/128/128"
return session
},
},
})
}
The default sign-in page can't be used because there is no way to hook wagmi to listen for clicks on the default sign-in page provided by next-auth, so a custom page must be created to handle the sign-in flow. Create
pages/siwe.tsx
and populate it with the following:
import { getCsrfToken, signIn, useSession } from "next-auth/react"
import { SiweMessage } from "siwe"
import { useAccount, useConnect, useNetwork, useSignMessage } from "wagmi"
import Layout from "../components/layout"
import { InjectedConnector } from 'wagmi/connectors/injected'
import { useEffect, useState } from "react"
function Siwe() {
const { signMessageAsync } = useSignMessage()
const { chain } = useNetwork()
const { address, isConnected } = useAccount()
const { connect } = useConnect({
connector: new InjectedConnector(),
});
const { data: session, status } = useSession()
const handleLogin = async () => {
try {
const callbackUrl = "/protected"
const message = new SiweMessage({
domain: window.location.host,
address: address,
statement: "Sign in with Ethereum to the app.",
uri: window.location.origin,
version: "1",
chainId: chain?.id,
nonce: await getCsrfToken(),
})
const signature = await signMessageAsync({
message: message.prepareMessage(),
})
signIn("credentials", {
message: JSON.stringify(message),
redirect: false,
signature,
callbackUrl,
})
} catch (error) {
window.alert(error)
}
}
useEffect(() => {
console.log(isConnected);
if (isConnected && !session) {
handleLogin()
}
}, [isConnected])
return (
<Layout>
<button
onClick={(e) => {
e.preventDefault()
if (!isConnected) {
connect()
} else {
handleLogin()
}
}}
>
Sign-in
</button>
</Layout>
)
}
export async function getServerSideProps(context: any) {
return {
props: {
csrfToken: await getCsrfToken(context),
},
}
}
Siwe.Layout = Layout
export default Siwe
Modify
pages/styles.css
by appending the following CSS:
button {
margin: 0 0 0.75rem 0;
text-decoration: none;
padding: 0.7rem 1.4rem;
border: 1px solid #346df1;
background-color: #346df1;
color: #fff;
font-size: 1rem;
border-radius: 4px;
transition: all 0.1s ease-in-out;
font-weight: 500;
position: relative;
}
button:hover {
cursor: pointer;
box-shadow: inset 0 0 5rem rgb(0 0 0 / 20%);
}
Finally, modify the components/header.tsx
in order to clean it up and add a SIWE tab to navigate to the newly created page:
import { signOut, useSession } from "next-auth/react"
import Link from "next/link"
import { useDisconnect } from "wagmi"
import styles from "./header.module.css"
// The approach used in this component shows how to build a sign in and sign out
// component that works on pages which support both client and server side
// rendering, and avoids any flash incorrect content on initial page load.
export default function Header() {
const { data: session, status } = useSession()
const loading = status === "loading"
const { disconnect } = useDisconnect()
return (
<header>
<noscript>
<style>{`.nojs-show { opacity: 1; top: 0; }`}</style>
</noscript>
<div className={styles.signedInStatus}>
<p
className={`nojs-show ${!session && loading ? styles.loading : styles.loaded}`}
>
{!session && (
<>
<span className={styles.notSignedInText}>
You are not signed in
</span>
</>
)}
{session?.user && (
<>
{session.user.image && (
<span
style={{ backgroundImage: `url('${session.user.image}')` }}
className={styles.avatar}
/>
)}
<span className={styles.signedInText}>
<small>Signed in as</small>
<br />
<strong>{session.user.email ?? session.user.name}</strong>
</span>
<a
href={`/api/auth/signout`}
className={styles.button}
onClick={(e) => {
e.preventDefault()
disconnect()
signOut()
}}
>
Sign out
</a>
</>
)}
</p>
</div>
<nav>
<ul className={styles.navItems}>
<li className={styles.navItem}>
<Link href="/">
Home
</Link>
</li>
<li className={styles.navItem}>
<Link href="/siwe">
SIWE
</Link>
</li>
</ul>
</nav>
</header>
)
}
Run the application using the following commands:
yarn install
yarn dev
Navigate to localhost:3000
- now you are now ready to Sign-In with Ethereum. Just click the SIWE
link in the header, hit the "Sign-In with Ethereum" button, sign the message, and you are now authenticated.
If you face the following error:
Error: Invalid <Link> with <a> child. Please remove <a> or use <Link legacyBehavior>.
go to components/footer.tsx
and remove the <a> tag from Policy at line 21.
Last updated