Aptos Dapp Journey - Part 4

John Fu LinJohn Fu Lin
13 min read

Introduction

In my last article, I explained how I implemented onchain data fetching and interaction. Now it's time to improve the user experience. There are several authentication options available for Aptos dApps, each with its own strengths and challenges. Let's explore these options and see how they can enhance the dApp's usability and accessibility. I will explain wallet adapters, Aptos Connect, and Aptos Keyless.

What are the Options to Improve User Authentication?

Web3 authentication has a bad UX story, as it requires managing cryptographic keys. Yet, there are several options on how to improve it. Let’s look at the three most popular ways used in the Aptos ecosystem.

Wallet Adapters

What I loved

Easiest setup for developers, full user control over their assets, and users can connect their wallets (Petra, Martian, etc.) to other apps.

What made me pause:

New users struggled with wallet setup, transaction signing popups, and users managing their keys safely.

Aptos Connect

Aptos Connect excites me with its simple web-based setup that requires no downloads, works across the Aptos ecosystem, and balances security with ease of use.

However, it is Aptos-specific and more limited compared to cross-chain wallets, and data is owned by Aptos Connect, although the dApp can request certain data.

Exploring Keyless

Onboarding became easy with social logins, eliminating the need for "install this wallet" instructions and removing sign transaction pop-ups.

The challenges: Accounts are tied to my dApp, which limits portability, and I need to build more account management features. Integrating it required some heavy lifting.

Despite these challenges, I was intrigued by the potential of keyless authentication. To better understand its benefits and implementation, let's dive deeper into how Aptos Keyless actually works under the hood.

How Aptos Keyless Works

aptos.dev

Some terminologies can be helpful to understand: EPK stands for Ephemeral Public Key, ESK is Ephemeral Secret Key, GPK refers to Google Public Key, GSK is Google Secret Key, JWT means JSON Web Token, EKP is Ephemeral KeyPair, and IdP is Id Provider (Google Sign In).

Aptos Keyless offers seamlessly blending familiar Web2 login experiences with Web3 functionality. Here's how it works:

Account Creation and Derivation

A user's blockchain address is derived from a hash of their email ID and the application ID, creating a unique, deterministic account for each user-app combination.

Authentication Flow

Users sign in through an OpenID Connect (OIDC) provider, such as Google or Apple. The OIDC provider then generates a signed JWT (JSON Web Token) that includes the user's identifier, like an email hash, the application's identifier, and any transaction data if applicable.

Transaction Authorization

Instead of using a traditional private key, transactions are authorized with the OIDC provider's signature. An ephemeral key pair is generated for each session to provide an extra layer of security.

Privacy Protection with Zero-Knowledge Proofs

Zero-knowledge proofs (ZKPs) are used to verify user identity and transaction authorization, ensuring that sensitive information remains private and is not revealed on the blockchain.

Validator Authentication

Blockchain validators authenticate transactions without accessing user login details. They verify the zero-knowledge proof (ZKP) to ensure the transaction signature matches the blockchain address.

Cross-Device Accessibility

Users can access their accounts on any device by simply logging in, without needing to import keys, download software, or set new passwords.

A Note About Ephemeral Key Pairs

By default, Aptos generates ephemeral key pairs with a 14-day expiry. They are used for transaction signing, not for determining the account address. Generating a new ephemeral key pair does not change the underlying account. New key pairs simply provide a fresh way to sign transactions for the existing account. Now that you understand the core concepts and components of keyless authentication, let's dive into the practical aspects of integrating this technology into our Aptos dApp.

How to Integrate Aptos Keyless

Keyless authentication in Aptos offers a seamless blend of web2 and web3 experiences. Let's break down the integration process and understand its key components.

Setting Up OpenID Authentication

I set up Google OpenID authentication for my Aptos dApp to enable Keyless login.

To do this, I needed to obtain the client ID and set the callback URL.

I created a new project in Google Cloud Console, then went to the OAuth consent screen.

I set up the consent screen

Select or create a new "OAuth 2.0 Client ID".

Configure the authorized origin to http://localhost:5173 or the port you use for local development. Set the redirect URIs to http://localhost:5173/callback (the handler for the callback after authentication, which will receive the authorization code and/or id_token).

Put the client ID in the .env file as VITE_GOOGLE_CLIENT_ID=. This process laid the groundwork for seamless, keyless authentication.

Adding Keyless to the Frontend

The Aptos Keyless example didn't work right away for some reason. Keyless integration wasn't straightforward, so most of the code is from the Aptosgotchi keyless example.

First, we create the Keyless Connect button component in frontend/components/KeylessButton/index.tsx.

This is how this part works when a user clicks on "Connect with Keyless":

  1. Create an ephemeral key pair and store it in Localstorage.

  2. Generate a nonce from this key pair.

  3. Include this nonce in the Google sign-in URL.

import { useKeylessAccount } from "@/context/KeylessAccountContext"
import useEphemeralKeyPair from "@/hooks/useEphemeralKeyPair"
import { collapseAddress } from "@/utils/address"
import { toast } from "sonner"
import GoogleLogo from "../GoogleLogo"
import { Button } from "../ui/button"

export default function KeylessButton() {
  if (!import.meta.env.VITE_GOOGLE_CLIENT_ID) {
    throw new Error("Google Client ID is not set in env")
  }

  const { keylessAccount, setKeylessAccount } = useKeylessAccount()
  const ephemeralKeyPair = useEphemeralKeyPair()

  const redirectUrl = new URL("https://accounts.google.com/o/oauth2/v2/auth")
  const searchParams = new URLSearchParams({
    client_id: import.meta.env.VITE_GOOGLE_CLIENT_ID,
    redirect_uri:
      typeof window !== "undefined"
        ? `${window.location.origin}/callback`
        : `${
            import.meta.env.DEV
              ? "http://localhost:3000"
              : import.meta.env.VITE_VERCEL_URL
          }/callback`,
    response_type: "id_token",
    scope: "openid email profile",
    nonce: ephemeralKeyPair.nonce,
  })
  redirectUrl.search = searchParams.toString()

  const disconnect = () => {
    setKeylessAccount(null)
    toast.success("Successfully disconnected account")
  }

  if (keylessAccount) {
    return (
      <div className="flex items-center justify-center m-auto sm:m-0 sm:px-4">
        <button type="button" onClick={disconnect} title="Disconnect Wallet">
          <GoogleLogo />
          <span title={keylessAccount.accountAddress.toString()}>
            {collapseAddress(keylessAccount.accountAddress.toString())}
          </span>
        </button>
      </div>
    )
  }

  return (
    <div className="flex items-center justify-center m-auto">
      <a href={redirectUrl.toString()} className="hover:no-underline">
        <Button size="sm" variant="default">
          Connect
        </Button>
      </a>
    </div>
  )
}

Then create the hook to store and retrieve the keyless account so the user stays logged in while navigating the web app: frontend/context/KeylessAccountContext.tsx.

import type { KeylessAccount } from "@aptos-labs/ts-sdk"
import type React from "react"
import { createContext, useContext, useState } from "react"

interface KeylessAccountContextType {
  keylessAccount: KeylessAccount | null
  setKeylessAccount: (account: KeylessAccount | null) => void
}

const KeylessAccountContext = createContext<
  KeylessAccountContextType | undefined
>(undefined)

export const KeylessAccountProvider: React.FC<{
  children: React.ReactNode
}> = ({ children }) => {
  const [keylessAccount, setKeylessAccount] = useState<KeylessAccount | null>(null)

  return (
    <KeylessAccountContext.Provider
      value={{ keylessAccount, setKeylessAccount }}
    >
      {children}
    </KeylessAccountContext.Provider>
  )
}

export const useKeylessAccount = () => {
  const context = useContext(KeylessAccountContext)
  if (!context) {
    throw new Error(
      "useKeylessAccount must be used within a KeylessAccountProvider"
    )
  }
  return context
}

Wrap our web app with the context. we add it to frontend/main.tsx page.

import "./index.css"

import React from "react"
import ReactDOM from "react-dom/client"

import App from "@/App.tsx"
import { WalletProvider } from "@/components/WalletProvider.tsx"
import { ApolloProvider } from "@apollo/client"
// Internal components
import { Toaster } from "./components/ui/sonner"
import { KeylessAccountProvider } from "./context/KeylessAccountContext"
import { QueryProvider } from "./lib/providers"
import client from "./utils/apollo-client"

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <WalletProvider>
      <QueryProvider>
        <ApolloProvider client={client}>
          <KeylessAccountProvider>
            <App />
          </KeylessAccountProvider>
          <Toaster richColors theme="light" position="bottom-right" />
        </ApolloProvider>
      </QueryProvider>
    </WalletProvider>
  </React.StrictMode>
)

Add the keyless connect button component and hook into frontend/components/WalletSelector.tsx, which is included in the Header. We display keyless alongside self-custodial wallets and Aptos Connect so users can easily access it.

// Internal components
import { Button } from "@/components/ui/button"
import {
  Collapsible,
  CollapsibleContent,
  CollapsibleTrigger,
} from "@/components/ui/collapsible"
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { useKeylessAccount } from "@/context/KeylessAccountContext"
import {
  APTOS_CONNECT_ACCOUNT_URL,
  AboutAptosConnect,
  type AboutAptosConnectEducationScreen,
  type AnyAptosWallet,
  AptosPrivacyPolicy,
  WalletItem,
  groupAndSortWallets,
  isAptosConnectWallet,
  isInstallRequired,
  truncateAddress,
  useWallet,
} from "@aptos-labs/wallet-adapter-react"
import {
  ArrowLeft,
  ArrowRight,
  ChevronDown,
  Copy,
  LogOut,
  User,
} from "lucide-react"
import { useCallback, useState } from "react"
import { toast } from "sonner"
import GoogleLogo from "./GoogleLogo"
import KeylessButton from "./KeylessButton"

export function WalletSelector() {
  const { account, connected, disconnect, wallet } = useWallet()
  const [isDialogOpen, setIsDialogOpen] = useState(false)
  const { keylessAccount, setKeylessAccount } = useKeylessAccount()

  const closeDialog = useCallback(() => setIsDialogOpen(false), [])

  // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
  const copyAddress = useCallback(async () => {
    if (keylessAccount) {
      try {
        await navigator.clipboard.writeText(
          keylessAccount.accountAddress.toString()
        )
        toast("Success", {
          description: "Copied wallet address to clipboard.",
        })
      } catch {
        toast.error("Error", {
          description: "Failed to copy wallet address.",
        })
      }
      return
    }

    if (!account?.address) return
    try {
      await navigator.clipboard.writeText(account.address)
      toast("Success", {
        description: "Copied wallet address to clipboard.",
      })
    } catch {
      toast.error("Error", {
        description: "Failed to copy wallet address.",
      })
    }
  }, [account?.address, toast, keylessAccount])

  const disconnectWallet = () => {
    if (keylessAccount) {
      setKeylessAccount(null)
    } else {
      disconnect()
    }
  }

  return connected || keylessAccount ? (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button>
          {account?.ansName ||
            truncateAddress(account?.address) ||
            truncateAddress(keylessAccount?.accountAddress.toString()) ||
            "Unknown"}
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="end">
        <DropdownMenuItem onSelect={copyAddress} className="gap-2">
          <Copy className="h-4 w-4" /> Copy address
        </DropdownMenuItem>
        {wallet && isAptosConnectWallet(wallet) && (
          <DropdownMenuItem asChild>
            <a
              href={APTOS_CONNECT_ACCOUNT_URL}
              target="_blank"
              rel="noopener noreferrer"
              className="flex gap-2"
            >
              <User className="h-4 w-4" /> Account
            </a>
          </DropdownMenuItem>
        )}
        <DropdownMenuItem onSelect={disconnectWallet} className="gap-2">
          <LogOut className="h-4 w-4" /> Disconnect
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  ) : (
    <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
      <DialogTrigger asChild>
        <Button>Connect a Wallet</Button>
      </DialogTrigger>
      <ConnectWalletDialog close={closeDialog} />
    </Dialog>
  )
}

interface ConnectWalletDialogProps {
  close: () => void
}

function ConnectWalletDialog({ close }: ConnectWalletDialogProps) {
  const { wallets = [] } = useWallet()
  const { aptosConnectWallets, availableWallets, installableWallets } =
    groupAndSortWallets(wallets)

  const hasAptosConnectWallets = !!aptosConnectWallets.length

  return (
    <DialogContent className="max-h-screen overflow-auto">
      <AboutAptosConnect renderEducationScreen={renderEducationScreen}>
        <DialogHeader>
          <DialogTitle className="flex flex-col text-center leading-snug">
            {hasAptosConnectWallets ? (
              <>
                <span>Log in or sign up</span>
                <span>with Social + Aptos Connect</span>
              </>
            ) : (
              "Connect Wallet"
            )}
          </DialogTitle>
        </DialogHeader>

        {hasAptosConnectWallets && (
          <div className="flex flex-col gap-2 pt-3">
            {aptosConnectWallets.map((wallet) => (
              <AptosConnectWalletRow
                key={wallet.name}
                wallet={wallet}
                onConnect={close}
              />
            ))}
            <p className="flex gap-1 justify-center items-center text-muted-foreground text-sm">
              Learn more about{" "}
              <AboutAptosConnect.Trigger className="flex gap-1 py-3 items-center text-foreground">
                Aptos Connect <ArrowRight size={16} />
              </AboutAptosConnect.Trigger>
            </p>
            <AptosPrivacyPolicy className="flex flex-col items-center py-1">
              <p className="text-xs leading-5">
                <AptosPrivacyPolicy.Disclaimer />{" "}
                <AptosPrivacyPolicy.Link className="text-muted-foreground underline underline-offset-4" />
                <span className="text-muted-foreground">.</span>
              </p>
              <AptosPrivacyPolicy.PoweredBy className="flex gap-1.5 items-center text-xs leading-5 text-muted-foreground" />
            </AptosPrivacyPolicy>
            <div className="flex items-center gap-3 pt-4 text-muted-foreground">
              <div className="h-px w-full bg-secondary" />
              Or
              <div className="h-px w-full bg-secondary" />
            </div>
          </div>
        )}

        <div className="animate-wiggle flex items-center justify-between px-4 mt-4 py-3 gap-4 border rounded-md border-highlight">
          <div className="flex items-center gap-4">
            <div className="h-6 w-6">
              <GoogleLogo />
            </div>
            <div className="text-base font-normal">Keyless App Wallet</div>
          </div>
          <div className="flex items-center gap-4">
            <KeylessButton />
          </div>
        </div>

        <div className="flex flex-col gap-3 pt-3">
          {availableWallets.map((wallet) => (
            <WalletRow key={wallet.name} wallet={wallet} onConnect={close} />
          ))}
          {!!installableWallets.length && (
            <Collapsible className="flex flex-col gap-3">
              <CollapsibleTrigger asChild>
                <Button size="sm" variant="ghost" className="gap-2">
                  More wallets <ChevronDown />
                </Button>
              </CollapsibleTrigger>
              <CollapsibleContent className="flex flex-col gap-3">
                {installableWallets.map((wallet) => (
                  <WalletRow
                    key={wallet.name}
                    wallet={wallet}
                    onConnect={close}
                  />
                ))}
              </CollapsibleContent>
            </Collapsible>
          )}
        </div>
      </AboutAptosConnect>
    </DialogContent>
  )
}

interface WalletRowProps {
  wallet: AnyAptosWallet
  onConnect?: () => void
}

function WalletRow({ wallet, onConnect }: WalletRowProps) {
  return (
    <WalletItem
      wallet={wallet}
      onConnect={onConnect}
      className="flex items-center justify-between px-4 py-3 gap-4 border rounded-md"
    >
      <div className="flex items-center gap-4">
        <WalletItem.Icon className="h-6 w-6" />
        <WalletItem.Name className="text-base font-normal" />
      </div>
      {isInstallRequired(wallet) ? (
        <Button size="sm" variant="ghost" asChild>
          <WalletItem.InstallLink />
        </Button>
      ) : (
        <WalletItem.ConnectButton asChild>
          <Button size="sm">Connect</Button>
        </WalletItem.ConnectButton>
      )}
    </WalletItem>
  )
}

function AptosConnectWalletRow({ wallet, onConnect }: WalletRowProps) {
  return (
    <WalletItem wallet={wallet} onConnect={onConnect}>
      <WalletItem.ConnectButton asChild>
        <Button size="lg" variant="outline" className="w-full gap-4">
          <WalletItem.Icon className="h-5 w-5" />
          <WalletItem.Name className="text-base font-normal" />
        </Button>
      </WalletItem.ConnectButton>
    </WalletItem>
  )
}

function renderEducationScreen(screen: AboutAptosConnectEducationScreen) {
  return (
    <>
      <DialogHeader className="grid grid-cols-[1fr_4fr_1fr] items-center space-y-0">
        <Button variant="ghost" size="icon" onClick={screen.cancel}>
          <ArrowLeft />
        </Button>
        <DialogTitle className="leading-snug text-base text-center">
          About Aptos Connect
        </DialogTitle>
      </DialogHeader>

      <div className="flex h-[162px] pb-3 items-end justify-center">
        <screen.Graphic />
      </div>
      <div className="flex flex-col gap-2 text-center pb-4">
        <screen.Title className="text-xl" />
        <screen.Description className="text-sm text-muted-foreground [&>a]:underline [&>a]:underline-offset-4 [&>a]:text-foreground" />
      </div>

      <div className="grid grid-cols-3 items-center">
        <Button
          size="sm"
          variant="ghost"
          onClick={screen.back}
          className="justify-self-start"
        >
          Back
        </Button>
        <div className="flex items-center gap-2 place-self-center">
          {screen.screenIndicators.map((ScreenIndicator, i) => (
            <ScreenIndicator key={i} className="py-4">
              <div className="h-0.5 w-6 transition-colors bg-muted [[data-active]>&]:bg-foreground" />
            </ScreenIndicator>
          ))}
        </div>
        <Button
          size="sm"
          variant="ghost"
          onClick={screen.next}
          className="gap-2 justify-self-end"
        >
          {screen.screenIndex === screen.totalScreens - 1 ? "Finish" : "Next"}
          <ArrowRight size={16} />
        </Button>
      </div>
    </>
  )
}

Let's create the callback page. The user will be sent to this page after signing in with their Google account. In frontend/pages/Callback.tsx

This is how this part works after the user signs in:

  1. Retrieve the JWT ID token from the callback.

  2. Verify that the JWT nonce matches the ephemeral key pair nonce.

  3. Derive the keyless account using the JWT and ephemeral key pair.

  4. Store this derived account in local storage for future sessions.

import { Progress } from "@/components/ui/progress"
import { useKeylessAccount } from "@/context/KeylessAccountContext"
import { getLocalEphemeralKeyPair } from "@/hooks/useEphemeralKeyPair"
import { getAptosClient } from "@/utils/aptosClient"
import type { EphemeralKeyPair } from "@aptos-labs/ts-sdk"
import { jwtDecode } from "jwt-decode"
import { useEffect, useState } from "react"
import { useNavigate } from "react-router-dom"
import { toast } from "sonner"

const parseJWTFromURL = (url: string): string | null => {
  const urlObject = new URL(url)
  const fragment = urlObject.hash.substring(1)
  const params = new URLSearchParams(fragment)
  return params.get("id_token")
}

function CallbackPage() {
  const { setKeylessAccount } = useKeylessAccount()
  const navigate = useNavigate()

  const [progress, setProgress] = useState<number>(0)
  const [hasError, setHasError] = useState<boolean>(false)

  // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
  useEffect(() => {
    const interval = setInterval(() => {
      setProgress((currentProgress) => {
        if (currentProgress >= 100) {
          clearInterval(interval)
          return 100
        }
        return currentProgress + 1
      })
    }, 50)

    async function deriveAccount() {
      const jwt = parseJWTFromURL(window.location.href)

      if (!jwt) {
        setHasError(true)
        setProgress(100)
        toast.error("No JWT found in URL. Please try logging in again.")
        return
      }

      const payload = jwtDecode<{ nonce: string }>(jwt)

      const jwtNonce = payload.nonce

      const ephemeralKeyPair = getLocalEphemeralKeyPair(jwtNonce)

      if (!ephemeralKeyPair) {
        setHasError(true)
        setProgress(100)
        toast.error(
          "No ephemeral key pair found for the given nonce. Please try logging in again."
        )
        return
      }

      await createKeylessAccount(jwt, ephemeralKeyPair)
      clearInterval(interval)
      setProgress(100)
      navigate("/")
    }

    deriveAccount()
  }, [])

  const createKeylessAccount = async (
    jwt: string,
    ephemeralKeyPair: EphemeralKeyPair
  ) => {
    const aptosClient = getAptosClient()
    const keylessAccount = await aptosClient.deriveKeylessAccount({
      jwt,
      ephemeralKeyPair,
    })

    const accountCoinsData = await aptosClient.getAccountCoinsData({
      accountAddress: keylessAccount?.accountAddress.toString(),
    })
    // account does not exist yet -> fund it
    if (accountCoinsData.length === 0) {
      try {
        await aptosClient.fundAccount({
          accountAddress: keylessAccount.accountAddress,
          amount: 200000000, // faucet 2 APT to create the account
        })
      } catch (error) {
        console.log("Error funding account: ", error)
        toast.error(
          "Failed to fund account. Please try logging in again or use another account."
        )
      }
    }

    console.log("Keyless Account: ", keylessAccount.accountAddress.toString())
    setKeylessAccount(keylessAccount)
  }

  return (
    <div className="flex flex-col items-center justify-center h-screen w-screen">
      <div className="bg-white rounded-lg shadow-lg p-8 max-w-md w-full">
        <h1 className="text-2xl font-bold text-center mb-4">
          Loading your account
        </h1>
        <Progress value={progress} className="w-full mb-4" />
        <p className="text-center text-gray-600">
          {hasError
            ? "An error occurred. Please try again."
            : `${progress}% complete`}
        </p>
        {progress === 100 && !hasError && (
          <p className="text-center text-green-600 mt-4">Redirecting...</p>
        )}
      </div>
    </div>
  )
}

export default CallbackPage

Add the callback to the router so it can be accessed through /callback in frontend/App.tsx.

const router = createBrowserRouter([
  {
    element: <Layout />,
    children: [
      {
        path: "/callback",
        element: <CallbackPage />,
      },
      // the rest
    ],
  },
])

Keyless Challenges and Limitations

With a popup, users know they are taking action, but with keyless, nothing happens. So, developers need to integrate UI feedback to notify users that the action was successful.

Users can get started easily, but ultimately it is linked to the web app. This means if the web app disappears tomorrow, users could potentially lose their wallet.

Currently, it is not the easiest to implement. Developers have to handle many more aspects, making it necessary to weigh if it is worth the extra effort.

Conclusion

As we've explored the landscape of authentication options for Aptos dApps, it's clear that developers have a range of powerful tools, each with its own strengths and considerations.

  • Wallet Adapters offer familiar blockchain interactions and full user control but can present onboarding challenges for new users.

  • Aptos Connect provides a web-based solution that balances security and ease of use, though it's specific to the Aptos ecosystem.

  • Keyless Authentication revolutionizes onboarding with social logins, eliminating the need for wallet installations and transaction popups.

Keyless solutions, in particular, show great potential to bridge the gap between Web2 and Web3 experiences, making blockchain technology more accessible to a broader audience. By reducing friction in the onboarding process and simplifying transaction authorizations, keyless solutions can significantly enhance the user experience. With that being said, we explored how keyless works and how to integrate it into the frontend.

0
Subscribe to my newsletter

Read articles from John Fu Lin directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

John Fu Lin
John Fu Lin

Follow your curiosity.