DEV Community

Cover image for Build an Web3 Authentication Method with Solana Wallets
llmas-laptrinh
llmas-laptrinh

Posted on

Build an Web3 Authentication Method with Solana Wallets

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The result look like image below:

Screenshot 2023-07-22 at 17.48.54.png

This is app router in Next 13.

Install dependencies

From your main project directory in your terminal, enter:

yarn add bs58 tweetnacl
Enter fullscreen mode Exit fullscreen mode

We will use tweetnacl and bs58 to Implement user identifier function.

yarn add next-auth
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

You can generate a secure secret by typing openssl rand -hex 32 in terminal:

Screenshot 2023-07-23 at 21.02.30.png

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 };
Enter fullscreen mode Exit fullscreen mode

we need add config for NextAuth()

  • Providers: In this case just one provider that uses the CredentialsProvider from next-auth. The CredentialsProvider is configured with message and signature fields used as the authentication credentials. The message and signature will send by client.
    • authorize function of the CredentialsProvider provided credentials and req for our validate.
    • Verifying the client host of the message matches the server 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>;
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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. The message and signature are two fields that we defined in the credentials property of CredentialsProvider.

Run Your Code

In root folder, open your terminal and enter:

yarn dev 
Enter fullscreen mode Exit fullscreen mode

You should see a page like this:

demo

Full Source

Reference

Top comments (0)