llmas-laptrinh
Posted on July 24, 2023
Overview
Web3 offers a unique authentication method using Web3 wallets. This method eliminates the need for traditional email login and provides a secure and private way for developers to implement authentication for users on their platforms.
In this example, we will explore how to use Solana wallets to create and sign messages, and verify their signatures in the back-end or we can use decentralized identifier (DID).
What You Will Do
In this guide, we will create a simple React app, that allow you authenticate user’s.
- Setting your react app.
- Implement front-end user interface.
- Integrate the Solana Wallet extension (e.g., Phantom).
- Create user identifier function with user’s wallet.
- Implement back-end APIs
What You Will Need
To follow along with this guide, you will need the following:
- Basic knowledge of the JavaScript, TypeScript, and React programming languages
- Nodejs installed (version 16.15 or higher)
- npm or yarn installed (We will be using yarn to initialize our project and install the necessary packages. Feel free to use npm instead if that is your preferred package manager)
- A modern browser with a Solana Wallet extension installed (e.g., Phantom)
Set Up Your React App
Creating a new project in your terminal following:
npx create-next-app@latest
On installation, you'll see the following prompts
Select “Yes” if you need to use it, otherwise select “No”.
What is your project named? my-app
Would you like to use TypeScript? No / Yes
Would you like to use ESLint? No / Yes
Would you like to use Tailwind CSS? No / Yes
Would you like to use `src/` directory? No / Yes
Would you like to use App Router? (recommended) No / Yes
Would you like to customize the default import alias? No / Yes
Read more about Nextjs installation. If you not familiar with Nextjs, you can create react app with Vite following:
yarn create vite
#or
npm create vite@latest
Setup project structure
These should include any database schema changes, or any changes to structured fields, e.g. an existing JSON column.
From your main project directory, create a “util” and “components” folder:
mkdir util components
The result look like image below:
This is app router in Next 13.
Install dependencies
From your main project directory in your terminal, enter:
yarn add bs58 tweetnacl
We will use tweetnacl and bs58 to Implement user identifier function.
yarn add next-auth
I use NextAuth.js for authentication in this example. If you create an app with Vite, feel free to use other libraries or your own solution.
Implement front-end user interface
In components folder, create AuthenButton folder and index.tsx
, type.ts
file
mkdir AuthenButton
cd AuthenButton
touch index.tsx type.ts
Similar to AuthenButton, create an Avatar component.
Go ahead and replace the contents of the entrie file file with the following:
AuthenButton/index.tsx
import React from "react";
import { AuthButtonProps } from "./types";
import { Avatar } from "../Avatar";
export function AuthenButton({
buttonlabel = "Connect",
buttonBackground,
avatarSrc = "",
customButton,
customAvatar,
onClick,
onAvatarClick,
address,
}: AuthButtonProps) {
const renderButton = () => {
return customButton ? (
customButton(buttonlabel, onClick)
) : (
<button
style={{ backgroundColor: buttonBackground }}
className="buttonContainer"
onClick={onClick}
>
{buttonlabel}
</button>
);
};
const renderAvatar = () => {
return customAvatar ? (
customAvatar(address, avatarSrc, onAvatarClick)
) : (
<div className="avatarContainer">
<p className="address" title={address}>
{address.slice(0, 5)}...
{address.slice(address.length - 5, address.length)}
</p>
<Avatar avatarSrc={avatarSrc} />
</div>
);
};
return (
<div>{!address || address === "" ? renderButton() : renderAvatar()}</div>
);
}
AuthenButton/types.ts
import { ReactNode } from "react";
export type AuthButtonProps = {
buttonlabel?: string;
buttonBackground?: string;
address: string;
avatarSrc?: string | any;
onClick?: () => void;
onAvatarClick?: () => void;
customButton?: (buttonlabel: string, onClick?: () => void) => ReactNode;
customAvatar?: (
address: string,
avatarSrc: string | any,
onClick?: () => void
) => ReactNode;
};
Avatar/index.tsx
/* eslint-disable @next/next/no-img-element */
// import Image from "next/image";
import React from "react";
type avatarProps = {
avatarSrc: string;
onClick?: () => void;
};
export function Avatar({ avatarSrc }: avatarProps) {
return (
<div className="container">
<img width={32} height={32} src={avatarSrc} alt="avatar" />
{/* <Image width={32} height={32} src={avatarSrc} alt="avatar" /> */}
</div>
);
}
Integrate the Solana Wallet extension
Make sure you install extension phantom to your browser.
The Phantom browser extension and mobile in-app browser are both designed to interact with web applications.
In this guide I will use Direct Integration Phantom to web application.
Direct Integration
The most direct way to interact with Phantom is via the provider that Phantom injects into your web application. This provider is globally available at
window.phantom
and its methods will always include Phantom's most up-to-date functionality. This documentation is dedicated to covering all aspects of the provider.
Another quick and easy way to get up and running with Phantom is via the Solana Wallet Adapter.
Now, Let's implement code to integrate with Phantom Wallet and get the wallet address.
You can see more detail in phantom docs.
create utli/index.ts
file:
export const getProvider = () => {
if ("phantom" in window) {
const { phantom }: any = window;
const provider = phantom.solana;
if (provider?.isPhantom) {
return provider;
}
}
return null;
};
update app/page.tsx
file:
"use client";
import React from "react";
import { getProvider } from "@/util";
import { AuthenButton } from "@/components";
export default function Home() {
const [walletAddress, setWalletAddress] = React.useState("");
const onConnect = async () => {
try {
const provider = getProvider();
if (!provider) {
window.open("https://phantom.app/", "_blank");
}
const resp = await provider.connect();
console.log("Connect", resp.publicKey.toString());
setWalletAddress(resp.publicKey.toString());
} catch (error) {
console.error(error);
}
};
return (
<main
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
}}
>
<AuthenButton
onClick={onConnect}
buttonlabel="SignIn by Wallet"
address={walletAddress}
/>
</main>
);
}
app/page.module.css
.main {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 70vh;
padding: 8px;
}
.main>div {
padding: 6px 0;
}
.header {
display: flex;
padding: 12px 8px;
border-bottom: 1px solid white;
}
.logoContainer {
flex: 1;
}
After running the app and clicking the button, your wallet address should appear in the console.
Create user identifier function with user’s wallet.
From your utils/signature.ts
folder:
import bs58 from "bs58";
import nacl from "tweetnacl";
interface signature {
signature: string;
publicKey: any;
}
export class Signature {
public static create(nonce: string) {
return new TextEncoder().encode(nonce);
}
public static async validate(
{ signature, publicKey }: signature,
nonceMsgUint8: Uint8Array
) {
const signatureUint8 = bs58.decode(signature);
const pubKeyUint8 = bs58.decode(publicKey);
return nacl.sign.detached.verify(
nonceMsgUint8,
signatureUint8,
pubKeyUint8
);
}
}
This code defines a class called "Signature," which is used to validate a signature.
A nonce (short for "number used once") is a unique value that
is generated and used once to prevent replay attacks. A replay attack is
a network attack in which a hacker intercepts and resends a valid data
transmission, tricking the system into accepting the same data multiple
times.
- create() method to encode a nonce and use it for verification.
- validate() method decodes the signature and the publicKey using the bs58 library, and then uses the nacl library to verify if the signature is valid using the nacl.sign.detached.verify method.
Implement back-end APIs
Next, create or replace the contents of the entrie file file with the following:
.env
NEXTAUTH_URL=http://localhost:3000/
NEXTAUTH_SECRET=YOUR_SECRET
You can generate a secure secret by typing openssl rand -hex 32
in terminal:
Make sure to update NEXTAUTH_SECRET
with that value.
app/api/auth/[...nextauth]/route.ts
import { Signature } from "@/util";
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { getCsrfToken } from "next-auth/react";
const providers = [
CredentialsProvider({
name: "web3-auth",
credentials: {
signature: {
label: "Signature",
type: "text",
},
message: {
label: "Message",
type: "text",
},
},
async authorize(credentials, req) {
const { publicKey, host } = JSON.parse(credentials?.message || "{}");
const nextAuthUrl = new URL(process.env.NEXTAUTH_URL || "");
if (host !== nextAuthUrl.host) {
return null;
}
const crsf = await getCsrfToken({ req: { ...req, body: null } });
if (!crsf) {
return null;
}
const nonceUnit8 = Signature.create(crsf);
const isValidate = await Signature.validate(
{
signature: credentials?.signature || "",
publicKey,
},
nonceUnit8
);
if (!isValidate) {
throw new Error("Could not validate the signed message");
}
return { id: publicKey };
},
}),
];
const handler = NextAuth({
session: {
strategy: "jwt",
},
providers,
callbacks: {
session({ session, token }) {
if (session.user) {
session.user.name = token.sub;
session.user.image = `https://ui-avatars.com/api/?name=${token.sub}`;
}
return session;
},
},
});
export { handler as GET, handler as POST };
we need add config for NextAuth()
-
Providers: In this case just one provider that uses the CredentialsProvider from
next-auth
. The CredentialsProvider is configured withmessage
andsignature
fields used as the authentication credentials. Themessage
andsignature
will send by client.- authorize function of the CredentialsProvider provided credentials and req for our validate.
- Verifying the
client host
of the message matches theserver host
of the NextAuth URL to prevent call API in third party tools. - Validating the signature of the client using the validate method of the Signature class
- Callbacks: The session callback that sets the publicKey and image URL of the user in the session.
- Session: Configure your session settings, such as determining whether to use JWT or a database, setting the idle session expiration duration, or implementing write operation throttling for database usage.
app/providers.tsx
"use client";
import { SessionProvider } from "next-auth/react";
type Props = {
children?: React.ReactNode;
};
export const NextAuthProvider = ({ children }: Props) => {
return <SessionProvider>{children}</SessionProvider>;
};
We will get session in the client so we need create a providers
component wrap the SessionProvider
. The SessionProvider component only runs on the client side, we need to create a providers component for it. This is because the layout.tsx
file runs on the server side, and the SessionProvider component cannot be used directly there.
app/layout.tsx
import "./globals.css";
import { NextAuthProvider } from "./providers";
import "./styles.css";
import type { Metadata } from "next";
import { Inter } from "next/font/google";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "llmas web3 auth",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>
<NextAuthProvider>{children}</NextAuthProvider>
</body>
</html>
);
}
app/page.tsx
"use client";
import { getCsrfToken, signIn, signOut } from "next-auth/react";
import styles from "./page.module.css";
import React from "react";
import { Signature, getProvider } from "@/util";
import { AuthenButton } from "@/components";
import bs58 from "bs58";
import { useSession } from "next-auth/react";
import Image from "next/image";
import Logo from "../public/logo.png";
export default function Home() {
const { data: session } = useSession();
const onConnect = async () => {
try {
const provider = getProvider();
if (!provider) {
window.open("https://phantom.app/", "_blank");
}
const resp = await provider.connect();
console.log("Connect", resp.publicKey.toString());
const csrf = await getCsrfToken();
if (resp && csrf) {
const noneUnit8 = Signature.create(csrf);
const { signature } = await provider.signMessage(noneUnit8);
const serializedSignature = bs58.encode(signature);
const message = {
host: window.location.host,
publicKey: resp.publicKey.toString(),
nonce: csrf,
};
const response = await signIn("credentials", {
message: JSON.stringify(message),
signature: serializedSignature,
redirect: false,
});
if (response?.error) {
console.log("Error occured:", response.error);
return;
}
} else {
console.log("Could not connect to wallet");
}
} catch (error) {
console.error(error);
}
};
return (
<>
<header className={styles.header}>
<div className={styles.logoContainer}>
<h1>logo</h1>
</div>
<AuthenButton
avatarSrc={session?.user?.image}
onClick={onConnect}
buttonlabel="SignIn by Wallet"
address={session?.user?.name || ""}
/>
</header>
<main className={styles.main}>
<Image width={64} height={64} src={Logo} alt="user avatar" />
<h4>llmas-laptrinh</h4>
<div>
<h3>Address: {session?.user?.name}</h3>
<p>Expires: {new Date(session?.expires || "").toTimeString()}</p>
</div>
{session !== null && (
<button className="buttonContainer" onClick={() => signOut()}>
SignOut
</button>
)}
</main>
</>
);
}
Let’s break this code
- The useSession() hook to get the current session data and status.
- We integrate with Phantom Wallet using getProvider(), such as connecting and creating
signMessage
. If don’t have provider, redirect user to dowload page. - We will send data to CredentialsProvider using the
signIn()
method. Themessage
andsignature
are two fields that we defined in thecredentials
property of CredentialsProvider.
Run Your Code
In root folder, open your terminal and enter:
yarn dev
You should see a page like this:
Full Source
Reference
- https://blog.logrocket.com/build-web3-authentication-flow-react-ether-js-ceramic/#setting-up-react-app-ceramic
- https://www.quicknode.com/guides/solana-development/dapps/how-to-authenticate-users-with-a-solana-wallet#overview
- https://reactjsexample.com/an-example-on-how-to-use-solana-wallet-adapter-as-a-web-authentication-method/
- https://codevoweb.com/setup-and-use-nextauth-in-nextjs-13-app-directory/
- https://dev.to/abhikbanerjee99/nextjs-13-using-next-auth-the-web3-way-17kg
Posted on July 24, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.