From 04c8656fd106d1e8ef5dc3bbcb85820b330d3a97 Mon Sep 17 00:00:00 2001 From: Mohammad Kermani Date: Sun, 23 Feb 2025 09:00:38 +0000 Subject: [PATCH 1/2] feat: switch from web2 auth to web3 auth --- .env.example | 3 + app/(auth)/actions.ts | 84 - app/(auth)/auth.config.ts | 46 +- app/(auth)/auth.ts | 112 +- app/(auth)/login/page.tsx | 67 - app/(auth)/register/page.tsx | 70 - app/config/index.tsx | 117 ++ app/context/index.tsx | 51 + app/layout.tsx | 58 +- components/auth-form.tsx | 60 - components/chat-header.tsx | 70 +- components/chat.tsx | 42 +- components/connect-button.tsx | 3 + components/model-selector.tsx | 81 - components/sign-out-form.tsx | 25 - components/submit-button.tsx | 38 - middleware.ts | 2 +- next.config.ts | 8 +- package.json | 7 + pnpm-lock.yaml | 3432 ++++++++++++++++++++++++++++++++- 20 files changed, 3746 insertions(+), 630 deletions(-) delete mode 100644 app/(auth)/actions.ts delete mode 100644 app/(auth)/login/page.tsx delete mode 100644 app/(auth)/register/page.tsx create mode 100644 app/config/index.tsx create mode 100644 app/context/index.tsx delete mode 100644 components/auth-form.tsx create mode 100644 components/connect-button.tsx delete mode 100644 components/model-selector.tsx delete mode 100644 components/sign-out-form.tsx delete mode 100644 components/submit-button.tsx diff --git a/.env.example b/.env.example index e831aad..4821f91 100644 --- a/.env.example +++ b/.env.example @@ -15,3 +15,6 @@ BLOB_READ_WRITE_TOKEN=**** # Instructions to create a database here: https://vercel.com/docs/storage/vercel-postgres/quickstart POSTGRES_URL=**** + +NEXT_PUBLIC_PROJECT_ID= +NEXTAUTH_SECRET= diff --git a/app/(auth)/actions.ts b/app/(auth)/actions.ts deleted file mode 100644 index 84f8ffd..0000000 --- a/app/(auth)/actions.ts +++ /dev/null @@ -1,84 +0,0 @@ -'use server'; - -import { z } from 'zod'; - -import { createUser, getUser } from '@/lib/db/queries'; - -import { signIn } from './auth'; - -const authFormSchema = z.object({ - email: z.string().email(), - password: z.string().min(6), -}); - -export interface LoginActionState { - status: 'idle' | 'in_progress' | 'success' | 'failed' | 'invalid_data'; -} - -export const login = async ( - _: LoginActionState, - formData: FormData, -): Promise => { - try { - const validatedData = authFormSchema.parse({ - email: formData.get('email'), - password: formData.get('password'), - }); - - await signIn('credentials', { - email: validatedData.email, - password: validatedData.password, - redirect: false, - }); - - return { status: 'success' }; - } catch (error) { - if (error instanceof z.ZodError) { - return { status: 'invalid_data' }; - } - - return { status: 'failed' }; - } -}; - -export interface RegisterActionState { - status: - | 'idle' - | 'in_progress' - | 'success' - | 'failed' - | 'user_exists' - | 'invalid_data'; -} - -export const register = async ( - _: RegisterActionState, - formData: FormData, -): Promise => { - try { - const validatedData = authFormSchema.parse({ - email: formData.get('email'), - password: formData.get('password'), - }); - - const [user] = await getUser(validatedData.email); - - if (user) { - return { status: 'user_exists' } as RegisterActionState; - } - await createUser(validatedData.email, validatedData.password); - await signIn('credentials', { - email: validatedData.email, - password: validatedData.password, - redirect: false, - }); - - return { status: 'success' }; - } catch (error) { - if (error instanceof z.ZodError) { - return { status: 'invalid_data' }; - } - - return { status: 'failed' }; - } -}; diff --git a/app/(auth)/auth.config.ts b/app/(auth)/auth.config.ts index cf1ecdd..d7d7a92 100644 --- a/app/(auth)/auth.config.ts +++ b/app/(auth)/auth.config.ts @@ -1,39 +1,29 @@ -import type { NextAuthConfig } from 'next-auth'; +import { NextAuthConfig } from "next-auth"; + +export const nextAuthSecret = process.env.NEXTAUTH_SECRET; +if (!nextAuthSecret) { + throw new Error("NEXTAUTH_SECRET is not set"); +} export const authConfig = { - pages: { - signIn: '/login', - newUser: '/', + secret: nextAuthSecret, + providers: [], + session: { + strategy: "jwt", }, - providers: [ - // added later in auth.ts since it requires bcrypt which is only compatible with Node.js - // while this file is also used in non-Node.js environments - ], callbacks: { - authorized({ auth, request: { nextUrl } }) { - const isLoggedIn = !!auth?.user; - const isOnChat = nextUrl.pathname.startsWith('/'); - const isOnRegister = nextUrl.pathname.startsWith('/register'); - const isOnLogin = nextUrl.pathname.startsWith('/login'); - - if (isLoggedIn && (isOnLogin || isOnRegister)) { - return Response.redirect(new URL('/', nextUrl as unknown as URL)); - } - - if (isOnRegister || isOnLogin) { - return true; // Always allow access to register and login pages - } - - if (isOnChat) { - if (isLoggedIn) return true; - return false; // Redirect unauthenticated users to login page + session({ session, token }) { + if (!token.sub) { + return session; } - if (isLoggedIn) { - return Response.redirect(new URL('/', nextUrl as unknown as URL)); + const [, chainId, address] = token.sub.split(":"); + if (chainId && address) { + session.address = address; + session.chainId = parseInt(chainId, 10); } - return true; + return session; }, }, } satisfies NextAuthConfig; diff --git a/app/(auth)/auth.ts b/app/(auth)/auth.ts index d8a4369..b016af7 100644 --- a/app/(auth)/auth.ts +++ b/app/(auth)/auth.ts @@ -1,15 +1,77 @@ -import { compare } from 'bcrypt-ts'; -import NextAuth, { type User, type Session } from 'next-auth'; -import Credentials from 'next-auth/providers/credentials'; +import NextAuth from "next-auth"; +import credentialsProvider from "next-auth/providers/credentials"; +import { getAddressFromMessage, getChainIdFromMessage, type SIWESession } from "@reown/appkit-siwe"; +import { createPublicClient, http } from "viem"; -import { getUser } from '@/lib/db/queries'; +import { authConfig } from "./auth.config"; -import { authConfig } from './auth.config'; +declare module "next-auth" { + interface Session extends SIWESession { + address: string; + chainId: number; + } +} + +export const nextAuthSecret = process.env.NEXTAUTH_SECRET; +if (!nextAuthSecret) { + throw new Error("NEXTAUTH_SECRET is not set"); +} -interface ExtendedSession extends Session { - user: User; +export const projectId = process.env.NEXT_PUBLIC_PROJECT_ID; +if (!projectId) { + throw new Error("NEXT_PUBLIC_PROJECT_ID is not set"); } +const providers = [ + credentialsProvider({ + name: "Ethereum", + credentials: { + message: { + label: "Message", + type: "text", + placeholder: "0x0", + }, + signature: { + label: "Signature", + type: "text", + placeholder: "0x0", + }, + }, + async authorize(credentials) { + try { + if (!credentials?.message) { + throw new Error("SiweMessage is undefined"); + } + const { message, signature } = credentials as Record; + const address = getAddressFromMessage(message); + const chainId = getChainIdFromMessage(message); + + const publicClient = createPublicClient({ + transport: http( + `https://rpc.walletconnect.org/v1/?chainId=${chainId}&projectId=${projectId}` + ), + }); + const isValid = await publicClient.verifyMessage({ + message, + address: address as `0x${string}`, + signature: signature as `0x${string}`, + }); + // end o view verifyMessage + + if (isValid) { + return { + id: `${chainId}:${address}`, + }; + } + + return null; + } catch (e) { + return null; + } + }, + }), +]; + export const { handlers: { GET, POST }, auth, @@ -17,39 +79,5 @@ export const { signOut, } = NextAuth({ ...authConfig, - providers: [ - Credentials({ - credentials: {}, - async authorize({ email, password }: any) { - const users = await getUser(email); - if (users.length === 0) return null; - // biome-ignore lint: Forbidden non-null assertion. - const passwordsMatch = await compare(password, users[0].password!); - if (!passwordsMatch) return null; - return users[0] as any; - }, - }), - ], - callbacks: { - async jwt({ token, user }) { - if (user) { - token.id = user.id; - } - - return token; - }, - async session({ - session, - token, - }: { - session: ExtendedSession; - token: any; - }) { - if (session.user) { - session.user.id = token.id as string; - } - - return session; - }, - }, + providers, }); diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx deleted file mode 100644 index 7ff4c73..0000000 --- a/app/(auth)/login/page.tsx +++ /dev/null @@ -1,67 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import { useActionState, useEffect, useState } from 'react'; -import { toast } from 'sonner'; - -import { AuthForm } from '@/components/auth-form'; -import { SubmitButton } from '@/components/submit-button'; - -import { login, type LoginActionState } from '../actions'; - -export default function Page() { - const router = useRouter(); - - const [email, setEmail] = useState(''); - const [isSuccessful, setIsSuccessful] = useState(false); - - const [state, formAction] = useActionState( - login, - { - status: 'idle', - }, - ); - - useEffect(() => { - if (state.status === 'failed') { - toast.error('Invalid credentials!'); - } else if (state.status === 'invalid_data') { - toast.error('Failed validating your submission!'); - } else if (state.status === 'success') { - setIsSuccessful(true); - router.refresh(); - } - }, [state.status, router]); - - const handleSubmit = (formData: FormData) => { - setEmail(formData.get('email') as string); - formAction(formData); - }; - - return ( -
-
-
-

Sign In

-

- Use your email and password to sign in -

-
- - Sign in -

- {"Don't have an account? "} - - Sign up - - {' for free.'} -

-
-
-
- ); -} diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx deleted file mode 100644 index 69e079b..0000000 --- a/app/(auth)/register/page.tsx +++ /dev/null @@ -1,70 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -import { useActionState, useEffect, useState } from 'react'; -import { toast } from 'sonner'; - -import { AuthForm } from '@/components/auth-form'; -import { SubmitButton } from '@/components/submit-button'; - -import { register, type RegisterActionState } from '../actions'; - -export default function Page() { - const router = useRouter(); - - const [email, setEmail] = useState(''); - const [isSuccessful, setIsSuccessful] = useState(false); - - const [state, formAction] = useActionState( - register, - { - status: 'idle', - }, - ); - - useEffect(() => { - if (state.status === 'user_exists') { - toast.error('Account already exists'); - } else if (state.status === 'failed') { - toast.error('Failed to create account'); - } else if (state.status === 'invalid_data') { - toast.error('Failed validating your submission!'); - } else if (state.status === 'success') { - toast.success('Account created successfully'); - setIsSuccessful(true); - router.refresh(); - } - }, [state, router]); - - const handleSubmit = (formData: FormData) => { - setEmail(formData.get('email') as string); - formAction(formData); - }; - - return ( -
-
-
-

Sign Up

-

- Create an account with your email and password -

-
- - Sign Up -

- {'Already have an account? '} - - Sign in - - {' instead.'} -

-
-
-
- ); -} diff --git a/app/config/index.tsx b/app/config/index.tsx new file mode 100644 index 0000000..a447826 --- /dev/null +++ b/app/config/index.tsx @@ -0,0 +1,117 @@ +import { WagmiAdapter } from "@reown/appkit-adapter-wagmi"; +import { + type SIWECreateMessageArgs, + type SIWESession, + type SIWEVerifyMessageArgs, + createSIWEConfig, + formatMessage, +} from "@reown/appkit-siwe"; +import { + AppKitNetwork, + arbitrum, + base, + mainnet, + optimism, +} from "@reown/appkit/networks"; +import { getCsrfToken, getSession, signIn, signOut } from "next-auth/react"; +import { getAddress } from "viem"; + +export const projectId = process.env.NEXT_PUBLIC_PROJECT_ID; +if (!projectId) throw new Error("Project ID is not defined"); + +export const metadata = { + name: "Pattern", + description: "Pattern is a decentralized agentic RAG network", + url: "https://pattern.global/", + icons: ["https://avatars.githubusercontent.com/u/112399339"], +}; + +export const chains: [AppKitNetwork, ...AppKitNetwork[]] = [ + mainnet, + base, + arbitrum, + optimism, +]; + +export const wagmiAdapter = new WagmiAdapter({ + networks: chains, + projectId, + ssr: true, +}); + +const normalizeAddress = (address: string): string => { + try { + const splitAddress = address.split(":"); + const extractedAddress = splitAddress[splitAddress.length - 1]; + const checksumAddress = getAddress(extractedAddress); + splitAddress[splitAddress.length - 1] = checksumAddress; + const normalizedAddress = splitAddress.join(":"); + + return normalizedAddress; + } catch (error) { + return address; + } +}; + +export const siweConfig = createSIWEConfig({ + getMessageParams: async () => ({ + domain: typeof window !== "undefined" ? window.location.host : "", + uri: typeof window !== "undefined" ? window.location.origin : "", + chains: chains.map((chain: AppKitNetwork) => parseInt(chain.id.toString())), + statement: "Please sign with your account", + }), + createMessage: ({ address, ...args }: SIWECreateMessageArgs) => + formatMessage(args, normalizeAddress(address)), + getNonce: async () => { + const nonce = await getCsrfToken(); + if (!nonce) { + throw new Error("Failed to get nonce!"); + } + + return nonce; + }, + getSession: async () => { + const session = await getSession(); + if (!session) { + return null; + } + + // Validate address and chainId types + if ( + typeof session.address !== "string" || + typeof session.chainId !== "number" + ) { + return null; + } + + return { + address: session.address, + chainId: session.chainId, + } satisfies SIWESession; + }, + verifyMessage: async ({ message, signature }: SIWEVerifyMessageArgs) => { + try { + const success = await signIn("credentials", { + message, + redirect: false, + signature, + callbackUrl: "/protected", + }); + + return Boolean(success?.ok); + } catch (error) { + return false; + } + }, + signOut: async () => { + try { + await signOut({ + redirect: false, + }); + + return true; + } catch (error) { + return false; + } + }, +}); diff --git a/app/context/index.tsx b/app/context/index.tsx new file mode 100644 index 0000000..09ba47d --- /dev/null +++ b/app/context/index.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { createAppKit } from "@reown/appkit/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import React, { ReactNode } from "react"; +import { State, WagmiProvider } from "wagmi"; + +import { + chains, + metadata, + projectId, + siweConfig, + wagmiAdapter, +} from "../config"; + +const queryClient = new QueryClient(); + +if (!projectId) throw new Error("Project ID is not defined"); + +createAppKit({ + adapters: [wagmiAdapter], + networks: chains, + projectId, + siweConfig, + metadata, + features: { + email: false, + socials: false, + send: false, + swaps: false, + onramp: false, + history: false, + }, +}); + +export default function AppKitProvider({ + children, + initialState, +}: { + children: ReactNode; + initialState?: State; +}) { + return ( + + {children} + + ); +} diff --git a/app/layout.tsx b/app/layout.tsx index 287847f..55e3bdd 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,22 +1,27 @@ -import type { Metadata } from 'next'; -import { Toaster } from 'sonner'; +import type { Metadata } from "next"; +import { headers } from "next/headers"; +import { Toaster } from "sonner"; +import { cookieToInitialState } from "wagmi"; -import { ThemeProvider } from '@/components/theme-provider'; +import { ThemeProvider } from "@/components/theme-provider"; -import './globals.css'; +import { wagmiAdapter } from "./config"; +import ContextProvider from "./context"; + +import "./globals.css"; export const metadata: Metadata = { - metadataBase: new URL('https://chat.vercel.ai'), - title: 'Next.js Chatbot Template', - description: 'Next.js chatbot template using the AI SDK.', + metadataBase: new URL("https://chat.vercel.ai"), + title: "Pattern", + description: "Pattern is a decentralized agentic RAG network", }; export const viewport = { - maximumScale: 1, // Disable auto-zoom on mobile Safari + maximumScale: 1, }; -const LIGHT_THEME_COLOR = 'hsl(0 0% 100%)'; -const DARK_THEME_COLOR = 'hsl(240deg 10% 3.92%)'; +const LIGHT_THEME_COLOR = "hsl(0 0% 100%)"; +const DARK_THEME_COLOR = "hsl(240deg 10% 3.92%)"; const THEME_COLOR_SCRIPT = `\ (function() { var html = document.documentElement; @@ -40,15 +45,12 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { + const headersObj = await headers(); + const cookies = headersObj.get("cookie"); + const initialState = cookieToInitialState(wagmiAdapter.wagmiConfig, cookies); + return ( - +