From 89f82253ca0c9e536b502234c95e863247bf67c6 Mon Sep 17 00:00:00 2001 From: Shreyaschorge Date: Wed, 16 Jul 2025 17:32:21 +0530 Subject: [PATCH] Revert "feat: replace next-auth with quick auth" This reverts commit 86029b2bd9cef2975b4aa922d18a6441730acbdb. --- bin/init.js | 7 +- package.json | 2 +- scripts/deploy.ts | 45 +- scripts/dev.js | 2 +- src/app/api/auth/[...nextauth]/route.ts | 6 + src/app/api/auth/update-session/route.ts | 46 ++ src/app/api/auth/validate/route.ts | 52 -- src/app/layout.tsx | 5 +- src/app/providers.tsx | 27 +- src/auth.ts | 443 +++++++++++++++++- .../ui/NeynarAuthButton/ProfileButton.tsx | 4 +- src/components/ui/NeynarAuthButton/index.tsx | 276 +++++++---- src/components/ui/wallet/SignIn.tsx | 91 ++-- src/hooks/useQuickAuth.ts | 204 -------- 14 files changed, 792 insertions(+), 418 deletions(-) create mode 100644 src/app/api/auth/[...nextauth]/route.ts create mode 100644 src/app/api/auth/update-session/route.ts delete mode 100644 src/app/api/auth/validate/route.ts delete mode 100644 src/hooks/useQuickAuth.ts diff --git a/bin/init.js b/bin/init.js index d366560..f177adf 100644 --- a/bin/init.js +++ b/bin/init.js @@ -460,7 +460,6 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe '@farcaster/miniapp-sdk': '>=0.1.6 <1.0.0', '@farcaster/miniapp-wagmi-connector': '^1.0.0', '@farcaster/mini-app-solana': '>=0.0.17 <1.0.0', - '@farcaster/quick-auth': '>=0.0.7 <1.0.0', '@neynar/react': '^1.2.5', '@radix-ui/react-label': '^2.1.1', '@solana/wallet-adapter-react': '^0.15.38', @@ -472,6 +471,7 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe 'lucide-react': '^0.469.0', mipd: '^0.0.7', next: '^15', + 'next-auth': '^4.24.11', react: '^19', 'react-dom': '^19', 'tailwind-merge': '^2.6.0', @@ -483,7 +483,6 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe }; packageJson.devDependencies = { - "@types/inquirer": "^9.0.8", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -495,8 +494,8 @@ export async function init(projectName = null, autoAcceptDefaults = false, apiKe "pino-pretty": "^13.0.0", "postcss": "^8", "tailwindcss": "^3.4.1", - "ts-node": "^10.9.2", - "typescript": "^5" + "typescript": "^5", + "ts-node": "^10.9.2" }; // Add Neynar SDK if selected diff --git a/package.json b/package.json index 6e3ffdd..46bbf50 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@neynar/create-farcaster-mini-app", - "version": "1.7.1", + "version": "1.6.2", "type": "module", "private": false, "access": "public", diff --git a/scripts/deploy.ts b/scripts/deploy.ts index 35e6850..e64d4d2 100755 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -5,6 +5,7 @@ import os from 'os'; import { fileURLToPath } from 'url'; import inquirer from 'inquirer'; import dotenv from 'dotenv'; +import crypto from 'crypto'; import { Vercel } from '@vercel/sdk'; import { APP_NAME, APP_BUTTON_TEXT } from '../src/lib/constants'; @@ -129,7 +130,7 @@ async function checkRequiredEnvVars(): Promise { process.env.SPONSOR_SIGNER = sponsorSigner.toString(); - if (process.env.SEED_PHRASE) { + if (storeSeedPhrase) { fs.appendFileSync( '.env.local', `\nSPONSOR_SIGNER="${sponsorSigner}"` @@ -286,26 +287,17 @@ async function setVercelEnvVarSDK(vercelClient: Vercel, projectId: string, key: } // Get existing environment variables - const existingVars = await vercelClient.projects.filterProjectEnvs({ + const existingVars = await vercelClient.projects.getEnvironmentVariables({ idOrName: projectId, }); - // Handle different response types - let envs: any[] = []; - if ('envs' in existingVars && Array.isArray(existingVars.envs)) { - envs = existingVars.envs; - } else if ('target' in existingVars && 'key' in existingVars) { - // Single environment variable response - envs = [existingVars]; - } - - const existingVar = envs.find((env: any) => + const existingVar = existingVars.envs?.find((env: any) => env.key === key && env.target?.includes('production') ); - if (existingVar && existingVar.id) { + if (existingVar) { // Update existing variable - await vercelClient.projects.editProjectEnv({ + await vercelClient.projects.editEnvironmentVariable({ idOrName: projectId, id: existingVar.id, requestBody: { @@ -316,7 +308,7 @@ async function setVercelEnvVarSDK(vercelClient: Vercel, projectId: string, key: console.log(`✅ Updated environment variable: ${key}`); } else { // Create new variable - await vercelClient.projects.createProjectEnv({ + await vercelClient.projects.createEnvironmentVariable({ idOrName: projectId, requestBody: { key: key, @@ -434,7 +426,7 @@ async function waitForDeployment(vercelClient: Vercel | null, projectId: string, while (Date.now() - startTime < maxWaitTime) { try { - const deployments = await vercelClient?.deployments.getDeployments({ + const deployments = await vercelClient?.deployments.list({ projectId: projectId, limit: 1, }); @@ -566,15 +558,12 @@ async function deployToVercel(useGitHub = false): Promise { if (vercelClient) { try { - const projects = await vercelClient.projects.getProjects({}); - const project = projects.projects.find(p => p.id === projectId || p.name === projectId); - if (project) { - projectName = project.name; - domain = `${projectName}.vercel.app`; - console.log('🌐 Using project name for domain:', domain); - } else { - throw new Error('Project not found'); - } + const project = await vercelClient.projects.get({ + idOrName: projectId, + }); + projectName = project.name; + domain = `${projectName}.vercel.app`; + console.log('🌐 Using project name for domain:', domain); } catch (error: unknown) { if (error instanceof Error) { console.warn('âš ī¸ Could not get project details via SDK, using CLI fallback'); @@ -626,7 +615,12 @@ async function deployToVercel(useGitHub = false): Promise { } // Prepare environment variables + const nextAuthSecret = + process.env.NEXTAUTH_SECRET || crypto.randomBytes(32).toString('hex'); const vercelEnv = { + NEXTAUTH_SECRET: nextAuthSecret, + AUTH_SECRET: nextAuthSecret, + NEXTAUTH_URL: `https://${domain}`, NEXT_PUBLIC_URL: `https://${domain}`, ...(process.env.NEYNAR_API_KEY && { NEYNAR_API_KEY: process.env.NEYNAR_API_KEY }), @@ -720,6 +714,7 @@ async function deployToVercel(useGitHub = false): Promise { console.log('🔄 Updating environment variables with correct domain...'); const updatedEnv: Record = { + NEXTAUTH_URL: `https://${actualDomain}`, NEXT_PUBLIC_URL: `https://${actualDomain}`, }; diff --git a/scripts/dev.js b/scripts/dev.js index 7a95688..a16a46f 100755 --- a/scripts/dev.js +++ b/scripts/dev.js @@ -149,7 +149,7 @@ async function startDev() { nextDev = spawn(nextBin, ['dev', '-p', port.toString()], { stdio: 'inherit', - env: { ...process.env, NEXT_PUBLIC_URL: miniAppUrl }, + env: { ...process.env, NEXT_PUBLIC_URL: miniAppUrl, NEXTAUTH_URL: miniAppUrl }, cwd: projectRoot, shell: process.platform === 'win32' // Add shell option for Windows }); diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..58262ba --- /dev/null +++ b/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,6 @@ +import NextAuth from "next-auth" +import { authOptions } from "~/auth" + +const handler = NextAuth(authOptions) + +export { handler as GET, handler as POST } diff --git a/src/app/api/auth/update-session/route.ts b/src/app/api/auth/update-session/route.ts new file mode 100644 index 0000000..db4b4fc --- /dev/null +++ b/src/app/api/auth/update-session/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '~/auth'; + +export async function POST(request: Request) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.fid) { + return NextResponse.json( + { error: 'No authenticated session found' }, + { status: 401 } + ); + } + + const body = await request.json(); + const { signers, user } = body; + + if (!signers || !user) { + return NextResponse.json( + { error: 'Signers and user are required' }, + { status: 400 } + ); + } + + // For NextAuth to update the session, we need to trigger the JWT callback + // This is typically done by calling the session endpoint with updated data + // However, we can't directly modify the session token from here + + // Instead, we'll store the data temporarily and let the client refresh the session + // The session will be updated when the JWT callback is triggered + + return NextResponse.json({ + success: true, + message: 'Session update prepared', + signers, + user, + }); + } catch (error) { + console.error('Error preparing session update:', error); + return NextResponse.json( + { error: 'Failed to prepare session update' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/auth/validate/route.ts b/src/app/api/auth/validate/route.ts deleted file mode 100644 index 70256f8..0000000 --- a/src/app/api/auth/validate/route.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { NextResponse } from 'next/server'; -import { createClient, Errors } from '@farcaster/quick-auth'; - -const client = createClient(); - -export async function POST(request: Request) { - try { - const { token } = await request.json(); - - if (!token) { - return NextResponse.json( - { error: 'Token is required' }, - { status: 400 } - ); - } - - // Get domain from environment or request - const domain = process.env.NEXT_PUBLIC_URL - ? new URL(process.env.NEXT_PUBLIC_URL).hostname - : request.headers.get('host') || 'localhost'; - - try { - // Use the official QuickAuth library to verify the JWT - const payload = await client.verifyJwt({ - token, - domain, - }); - - return NextResponse.json({ - success: true, - user: { - fid: payload.sub, - }, - }); - } catch (e) { - if (e instanceof Errors.InvalidTokenError) { - console.info('Invalid token:', e.message); - return NextResponse.json( - { error: 'Invalid token' }, - { status: 401 } - ); - } - throw e; - } - } catch (error) { - console.error('Token validation error:', error); - return NextResponse.json( - { error: 'Internal server error' }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index acf3b41..b7a8dde 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; +import { getSession } from "~/auth" import "~/app/globals.css"; import { Providers } from "~/app/providers"; import { APP_NAME, APP_DESCRIPTION } from "~/lib/constants"; @@ -14,10 +15,12 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { + const session = await getSession() + return ( - {children} + {children} ); diff --git a/src/app/providers.tsx b/src/app/providers.tsx index b5884a5..90584eb 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,9 +1,12 @@ 'use client'; import dynamic from 'next/dynamic'; +import type { Session } from 'next-auth'; +import { SessionProvider } from 'next-auth/react'; import { MiniAppProvider } from '@neynar/react'; import { SafeFarcasterSolanaProvider } from '~/components/providers/SafeFarcasterSolanaProvider'; import { ANALYTICS_ENABLED } from '~/lib/constants'; +import { AuthKitProvider } from '@farcaster/auth-kit'; const WagmiProvider = dynamic( () => import('~/components/providers/WagmiProvider'), @@ -13,22 +16,26 @@ const WagmiProvider = dynamic( ); export function Providers({ + session, children, }: { + session: Session | null; children: React.ReactNode; }) { const solanaEndpoint = process.env.SOLANA_RPC_ENDPOINT || 'https://solana-rpc.publicnode.com'; return ( - - - - {children} - - - + + + + + {children} + + + + ); } diff --git a/src/auth.ts b/src/auth.ts index 5733ae5..c3345fb 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,10 +1,439 @@ -import { sdk } from '@farcaster/miniapp-sdk'; +import { AuthOptions, getServerSession } from 'next-auth'; +import CredentialsProvider from 'next-auth/providers/credentials'; +import { createAppClient, viemConnector } from '@farcaster/auth-client'; -// Export QuickAuth from the SDK -export const quickAuth = sdk.quickAuth; +declare module 'next-auth' { + interface Session { + provider?: string; + user?: { + fid: number; + object?: 'user'; + username?: string; + display_name?: string; + pfp_url?: string; + custody_address?: string; + profile?: { + bio: { + text: string; + mentioned_profiles?: Array<{ + object: 'user_dehydrated'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + }>; + mentioned_profiles_ranges?: Array<{ + start: number; + end: number; + }>; + }; + location?: { + latitude: number; + longitude: number; + address: { + city: string; + state: string; + country: string; + country_code: string; + }; + }; + }; + follower_count?: number; + following_count?: number; + verifications?: string[]; + verified_addresses?: { + eth_addresses: string[]; + sol_addresses: string[]; + primary: { + eth_address: string; + sol_address: string; + }; + }; + verified_accounts?: Array>; + power_badge?: boolean; + url?: string; + experimental?: { + neynar_user_score: number; + deprecation_notice: string; + }; + score?: number; + }; + signers?: { + object: 'signer'; + signer_uuid: string; + public_key: string; + status: 'approved'; + fid: number; + }[]; + } -// Helper function to get session (for server-side compatibility) -export const getSession = async () => { - // For QuickAuth, sessions are managed by the SDK - return null; + interface User { + provider?: string; + signers?: Array<{ + object: 'signer'; + signer_uuid: string; + public_key: string; + status: 'approved'; + fid: number; + }>; + user?: { + object: 'user'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + profile: { + bio: { + text: string; + mentioned_profiles?: Array<{ + object: 'user_dehydrated'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + }>; + mentioned_profiles_ranges?: Array<{ + start: number; + end: number; + }>; + }; + location?: { + latitude: number; + longitude: number; + address: { + city: string; + state: string; + country: string; + country_code: string; + }; + }; + }; + follower_count: number; + following_count: number; + verifications: string[]; + verified_addresses: { + eth_addresses: string[]; + sol_addresses: string[]; + primary: { + eth_address: string; + sol_address: string; + }; + }; + verified_accounts: Array>; + power_badge: boolean; + url?: string; + experimental?: { + neynar_user_score: number; + deprecation_notice: string; + }; + score: number; + }; + } + + interface JWT { + provider?: string; + signers?: Array<{ + object: 'signer'; + signer_uuid: string; + public_key: string; + status: 'approved'; + fid: number; + }>; + user?: { + object: 'user'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + profile: { + bio: { + text: string; + mentioned_profiles?: Array<{ + object: 'user_dehydrated'; + fid: number; + username: string; + display_name: string; + pfp_url: string; + custody_address: string; + }>; + mentioned_profiles_ranges?: Array<{ + start: number; + end: number; + }>; + }; + location?: { + latitude: number; + longitude: number; + address: { + city: string; + state: string; + country: string; + country_code: string; + }; + }; + }; + follower_count: number; + following_count: number; + verifications: string[]; + verified_addresses: { + eth_addresses: string[]; + sol_addresses: string[]; + primary: { + eth_address: string; + sol_address: string; + }; + }; + verified_accounts?: Array>; + power_badge?: boolean; + url?: string; + experimental?: { + neynar_user_score: number; + deprecation_notice: string; + }; + score?: number; + }; + } +} + +function getDomainFromUrl(urlString: string | undefined): string { + if (!urlString) { + console.warn('NEXTAUTH_URL is not set, using localhost:3000 as fallback'); + return 'localhost:3000'; + } + try { + const url = new URL(urlString); + return url.hostname; + } catch (error) { + console.error('Invalid NEXTAUTH_URL:', urlString, error); + console.warn('Using localhost:3000 as fallback'); + return 'localhost:3000'; + } +} + +export const authOptions: AuthOptions = { + // Configure one or more authentication providers + providers: [ + CredentialsProvider({ + id: 'farcaster', + name: 'Sign in with Farcaster', + credentials: { + message: { + label: 'Message', + type: 'text', + placeholder: '0x0', + }, + signature: { + label: 'Signature', + type: 'text', + placeholder: '0x0', + }, + nonce: { + label: 'Nonce', + type: 'text', + placeholder: 'Custom nonce (optional)', + }, + // In a production app with a server, these should be fetched from + // your Farcaster data indexer rather than have them accepted as part + // of credentials. + // question: should these natively use the Neynar API? + name: { + label: 'Name', + type: 'text', + placeholder: '0x0', + }, + pfp: { + label: 'Pfp', + type: 'text', + placeholder: '0x0', + }, + }, + async authorize(credentials, req) { + const nonce = req?.body?.csrfToken; + + if (!nonce) { + console.error('No nonce or CSRF token provided'); + return null; + } + const appClient = createAppClient({ + ethereum: viemConnector(), + }); + + const domain = getDomainFromUrl(process.env.NEXTAUTH_URL); + + const verifyResponse = await appClient.verifySignInMessage({ + message: credentials?.message as string, + signature: credentials?.signature as `0x${string}`, + domain, + nonce, + }); + + const { success, fid } = verifyResponse; + + if (!success) { + return null; + } + + return { + id: fid.toString(), + name: credentials?.name || `User ${fid}`, + image: credentials?.pfp || null, + provider: 'farcaster', + }; + }, + }), + CredentialsProvider({ + id: 'neynar', + name: 'Sign in with Neynar', + credentials: { + message: { + label: 'Message', + type: 'text', + placeholder: '0x0', + }, + signature: { + label: 'Signature', + type: 'text', + placeholder: '0x0', + }, + nonce: { + label: 'Nonce', + type: 'text', + placeholder: 'Custom nonce (optional)', + }, + fid: { + label: 'FID', + type: 'text', + placeholder: '0', + }, + signers: { + label: 'Signers', + type: 'text', + placeholder: 'JSON string of signers', + }, + user: { + label: 'User Data', + type: 'text', + placeholder: 'JSON string of user data', + }, + }, + async authorize(credentials) { + const nonce = credentials?.nonce; + + if (!nonce) { + console.error('No nonce or CSRF token provided for Neynar auth'); + return null; + } + + // For Neynar, we can use a different validation approach + // This could involve validating against Neynar's API or using their SDK + try { + // Validate the signature using Farcaster's auth client (same as Farcaster provider) + const appClient = createAppClient({ + ethereum: viemConnector(), + }); + + const domain = getDomainFromUrl(process.env.NEXTAUTH_URL); + + const verifyResponse = await appClient.verifySignInMessage({ + message: credentials?.message as string, + signature: credentials?.signature as `0x${string}`, + domain, + nonce, + }); + + const { success, fid } = verifyResponse; + + if (!success) { + return null; + } + + // Validate that the provided FID matches the verified FID + if (credentials?.fid && parseInt(credentials.fid) !== fid) { + console.error('FID mismatch in Neynar auth'); + return null; + } + + return { + id: fid.toString(), + provider: 'neynar', + signers: credentials?.signers + ? JSON.parse(credentials.signers) + : undefined, + user: credentials?.user ? JSON.parse(credentials.user) : undefined, + }; + } catch (error) { + console.error('Error in Neynar auth:', error); + return null; + } + }, + }), + ], + callbacks: { + session: async ({ session, token }) => { + // Set provider at the root level + session.provider = token.provider as string; + + if (token.provider === 'farcaster') { + // For Farcaster, simple structure + session.user = { + fid: parseInt(token.sub ?? ''), + }; + } else if (token.provider === 'neynar') { + // For Neynar, use full user data structure from user + session.user = token.user as typeof session.user; + session.signers = token.signers as typeof session.signers; + } + + return session; + }, + jwt: async ({ token, user }) => { + if (user) { + token.provider = user.provider; + token.signers = user.signers; + token.user = user.user; + } + return token; + }, + }, + cookies: { + sessionToken: { + name: `next-auth.session-token`, + options: { + httpOnly: true, + sameSite: 'none', + path: '/', + secure: true, + }, + }, + callbackUrl: { + name: `next-auth.callback-url`, + options: { + sameSite: 'none', + path: '/', + secure: true, + }, + }, + csrfToken: { + name: `next-auth.csrf-token`, + options: { + httpOnly: true, + sameSite: 'none', + path: '/', + secure: true, + }, + }, + }, +}; + +export const getSession = async () => { + try { + return await getServerSession(authOptions); + } catch (error) { + console.error('Error getting server session:', error); + return null; + } }; diff --git a/src/components/ui/NeynarAuthButton/ProfileButton.tsx b/src/components/ui/NeynarAuthButton/ProfileButton.tsx index be3c688..bcf1ca7 100644 --- a/src/components/ui/NeynarAuthButton/ProfileButton.tsx +++ b/src/components/ui/NeynarAuthButton/ProfileButton.tsx @@ -16,8 +16,8 @@ export function ProfileButton({ useDetectClickOutside(ref, () => setShowDropdown(false)); - const name = userData?.username && userData.username.trim() !== '' ? userData.username : `!${userData?.fid}`; - const pfpUrl = userData?.pfpUrl && userData.pfpUrl.trim() !== '' ? userData.pfpUrl : 'https://farcaster.xyz/avatar.png'; + const name = userData?.username ?? `!${userData?.fid}`; + const pfpUrl = userData?.pfpUrl ?? 'https://farcaster.xyz/avatar.png'; return (
diff --git a/src/components/ui/NeynarAuthButton/index.tsx b/src/components/ui/NeynarAuthButton/index.tsx index fd98c69..0eb1605 100644 --- a/src/components/ui/NeynarAuthButton/index.tsx +++ b/src/components/ui/NeynarAuthButton/index.tsx @@ -1,5 +1,7 @@ 'use client'; +import '@farcaster/auth-kit/styles.css'; +import { useSignIn, UseSignInData } from '@farcaster/auth-kit'; import { useCallback, useEffect, useState, useRef } from 'react'; import { cn } from '~/lib/utils'; import { Button } from '~/components/ui/Button'; @@ -7,8 +9,12 @@ import { AuthDialog } from '~/components/ui/NeynarAuthButton/AuthDialog'; import { ProfileButton } from '~/components/ui/NeynarAuthButton/ProfileButton'; import { getItem, removeItem, setItem } from '~/lib/localStorage'; import { useMiniApp } from '@neynar/react'; +import { + signIn as backendSignIn, + signOut as backendSignOut, + useSession, +} from 'next-auth/react'; import sdk, { SignIn as SignInCore } from '@farcaster/frame-sdk'; -import { useQuickAuth } from '~/hooks/useQuickAuth'; type User = { fid: number; @@ -18,6 +24,7 @@ type User = { // Add other user properties as needed }; +const STORAGE_KEY = 'neynar_authenticated_user'; const FARCASTER_FID = 9152; interface StoredAuthState { @@ -91,8 +98,7 @@ export function NeynarAuthButton() { const [storedAuth, setStoredAuth] = useState(null); const [signersLoading, setSignersLoading] = useState(false); const { context } = useMiniApp(); - const { authenticatedUser: quickAuthUser, signIn: quickAuthSignIn, signOut: quickAuthSignOut } = useQuickAuth(); - + const { data: session } = useSession(); // New state for unified dialog flow const [showDialog, setShowDialog] = useState(false); const [dialogStep, setDialogStep] = useState<'signin' | 'access' | 'loading'>( @@ -108,7 +114,6 @@ export function NeynarAuthButton() { const [signature, setSignature] = useState(null); const [isSignerFlowRunning, setIsSignerFlowRunning] = useState(false); const signerFlowStartedRef = useRef(false); - const [backendUserProfile, setBackendUserProfile] = useState<{ username?: string; pfpUrl?: string }>({}); // Determine which flow to use based on context const useBackendFlow = context !== undefined; @@ -141,15 +146,25 @@ export function NeynarAuthButton() { if (!useBackendFlow) return; try { - // For backend flow, use QuickAuth to sign in - if (signers && signers.length > 0) { - await quickAuthSignIn(); + // For backend flow, we need to sign in again with the additional data + if (message && signature) { + const signInData = { + message, + signature, + redirect: false, + nonce: nonce || '', + fid: user?.fid?.toString() || '', + signers: JSON.stringify(signers), + user: JSON.stringify(user), + }; + + await backendSignIn('neynar', signInData); } } catch (error) { console.error('❌ Error updating session with signers:', error); } }, - [useBackendFlow, quickAuthSignIn] + [useBackendFlow, message, signature, nonce] ); // Helper function to fetch user data from Neynar API @@ -230,17 +245,15 @@ export function NeynarAuthButton() { if (response.ok) { if (useBackendFlow) { // For backend flow, update session with signers - if (signerData.signers && signerData.signers.length > 0) { - // Get user data for the first signer - let user: StoredAuthState['user'] | null = null; - if (signerData.signers[0].fid) { - user = await fetchUserData(signerData.signers[0].fid) as StoredAuthState['user']; - } + if (signerData.signers && signerData.signers.length > 0) { + const user = + signerData.user || + (await fetchUserData(signerData.signers[0].fid)); await updateSessionWithSigners(signerData.signers, user); } return signerData.signers; } else { - // For frontend flow, store in memory only + // For frontend flow, store in localStorage let user: StoredAuthState['user'] | null = null; if (signerData.signers && signerData.signers.length > 0) { @@ -250,12 +263,13 @@ export function NeynarAuthButton() { user = fetchedUser; } - // Store signers in memory only + // Store signers in localStorage, preserving existing auth data const updatedState: StoredAuthState = { isAuthenticated: !!user, signers: signerData.signers || [], user, }; + setItem(STORAGE_KEY, updatedState); setStoredAuth(updatedState); return signerData.signers; @@ -511,71 +525,76 @@ export function NeynarAuthButton() { // Fetch user profile when quickAuthUser.fid changes (for backend flow) useEffect(() => { - if (useBackendFlow && quickAuthUser?.fid) { - (async () => { - const user = await fetchUserData(quickAuthUser.fid); - setBackendUserProfile({ - username: user?.username || '', - pfpUrl: user?.pfp_url || '', - }); - })(); + if (!useBackendFlow) { + const stored = getItem(STORAGE_KEY); + if (stored && stored.isAuthenticated) { + setStoredAuth(stored); + } } - }, [useBackendFlow, quickAuthUser?.fid, fetchUserData]); + }, [useBackendFlow]); - const handleFrontEndSignIn = useCallback(async () => { - try { - setSignersLoading(true); - const result = await sdk.actions.signIn({ nonce: nonce || '' }); - - setMessage(result.message); - setSignature(result.signature); - - // For frontend flow, we'll handle the signer flow in the useEffect - } catch (e) { - if (e instanceof SignInCore.RejectedByUser) { - console.log('â„šī¸ Sign-in rejected by user'); - } else { - console.error('❌ Frontend sign-in error:', e); + // Success callback - this is critical! + const onSuccessCallback = useCallback( + async (res: UseSignInData) => { + if (!useBackendFlow) { + // Only handle localStorage for frontend flow + const existingAuth = getItem(STORAGE_KEY); + const user = res.fid ? await fetchUserData(res.fid) : null; + const authState: StoredAuthState = { + ...existingAuth, + isAuthenticated: true, + user: user as StoredAuthState['user'], + signers: existingAuth?.signers || [], // Preserve existing signers + }; + setItem(STORAGE_KEY, authState); + setStoredAuth(authState); } - } finally { - setSignersLoading(false); - } - }, [nonce]); + // For backend flow, the session will be handled by NextAuth + }, + [useBackendFlow, fetchUserData] + ); - const handleSignOut = useCallback(async () => { - try { - setSignersLoading(true); + // Error callback + const onErrorCallback = useCallback((error?: Error | null) => { + console.error('❌ Sign in error:', error); + }, []); - if (useBackendFlow) { - // Use QuickAuth sign out - await quickAuthSignOut(); - } else { - // Frontend flow sign out - setStoredAuth(null); - } + const signInState = useSignIn({ + nonce: nonce || undefined, + onSuccess: onSuccessCallback, + onError: onErrorCallback, + }); - // Common cleanup for both flows - setShowDialog(false); - setDialogStep('signin'); - setSignerApprovalUrl(null); - setMessage(null); - setSignature(null); + const { + signIn: frontendSignIn, + signOut: frontendSignOut, + connect, + reconnect, + isSuccess, + isError, + error, + channelToken, + url, + data, + validSignature, + } = signInState; - // Reset polling interval - if (pollingInterval) { - clearInterval(pollingInterval); - setPollingInterval(null); - } - - // Reset signer flow flag + useEffect(() => { + setMessage(data?.message || null); + setSignature(data?.signature || null); + + // Reset the signer flow flag when message/signature change + if (data?.message && data?.signature) { signerFlowStartedRef.current = false; - } catch (error) { - console.error('❌ Error during sign out:', error); - // Optionally handle error state - } finally { - setSignersLoading(false); } - }, [useBackendFlow, pollingInterval, quickAuthSignOut]); + }, [data?.message, data?.signature]); + + // Connect for frontend flow when nonce is available + useEffect(() => { + if (!useBackendFlow && nonce && !channelToken) { + connect(); + } + }, [useBackendFlow, nonce, channelToken, connect]); // Handle fetching signers after successful authentication useEffect(() => { @@ -653,15 +672,103 @@ export function NeynarAuthButton() { } }, [message, signature]); // Simplified dependencies + // Backend flow using NextAuth + const handleBackendSignIn = useCallback(async () => { + if (!nonce) { + console.error('❌ No nonce available for backend sign-in'); + return; + } + + try { + setSignersLoading(true); + const result = await sdk.actions.signIn({ nonce }); + + const signInData = { + message: result.message, + signature: result.signature, + redirect: false, + nonce: nonce, + }; + + const nextAuthResult = await backendSignIn('neynar', signInData); + if (nextAuthResult?.ok) { + setMessage(result.message); + setSignature(result.signature); + } else { + console.error('❌ NextAuth sign-in failed:', nextAuthResult); + } + } catch (e) { + if (e instanceof SignInCore.RejectedByUser) { + console.log('â„šī¸ Sign-in rejected by user'); + } else { + console.error('❌ Backend sign-in error:', e); + } + } + }, [nonce]); + + const handleFrontEndSignIn = useCallback(() => { + if (isError) { + reconnect(); + } + setDialogStep('signin'); + setShowDialog(true); + frontendSignIn(); + }, [isError, reconnect, frontendSignIn]); + + const handleSignOut = useCallback(async () => { + try { + setSignersLoading(true); + + if (useBackendFlow) { + // Only sign out from NextAuth if the current session is from Neynar provider + if (session?.provider === 'neynar') { + await backendSignOut({ redirect: false }); + } + } else { + // Frontend flow sign out + frontendSignOut(); + removeItem(STORAGE_KEY); + setStoredAuth(null); + } + + // Common cleanup for both flows + setShowDialog(false); + setDialogStep('signin'); + setSignerApprovalUrl(null); + setMessage(null); + setSignature(null); + + // Reset polling interval + if (pollingInterval) { + clearInterval(pollingInterval); + setPollingInterval(null); + } + + // Reset signer flow flag + signerFlowStartedRef.current = false; + } catch (error) { + console.error('❌ Error during sign out:', error); + // Optionally handle error state + } finally { + setSignersLoading(false); + } + }, [useBackendFlow, frontendSignOut, pollingInterval, session]); + const authenticated = useBackendFlow - ? !!quickAuthUser?.fid - : storedAuth?.isAuthenticated && !!(storedAuth?.signers && storedAuth.signers.length > 0); + ? !!( + session?.provider === 'neynar' && + session?.user?.fid && + session?.signers && + session.signers.length > 0 + ) + : ((isSuccess && validSignature) || storedAuth?.isAuthenticated) && + !!(storedAuth?.signers && storedAuth.signers.length > 0); const userData = useBackendFlow ? { - fid: quickAuthUser?.fid, - username: backendUserProfile.username ?? '', - pfpUrl: backendUserProfile.pfpUrl ?? '', + fid: session?.user?.fid, + username: session?.user?.username || '', + pfpUrl: session?.user?.pfp_url || '', } : { fid: storedAuth?.user?.fid, @@ -690,17 +797,18 @@ export function NeynarAuthButton() { ) : ( )} - {status === 'authenticated' && ( + {status === 'authenticated' && session?.provider === 'farcaster' && ( )} {/* Session Information */} - {authenticatedUser && ( + {session && (
-
Authenticated User
+
Session
- {JSON.stringify(authenticatedUser, null, 2)} + {JSON.stringify(session, null, 2)}
)} @@ -115,10 +142,20 @@ export function SignIn() { {/* Error Display */} {signInFailure && !authState.signingIn && (
-
Authentication Error
+
SIWF Result
{signInFailure}
)} + + {/* Success Result Display */} + {signInResult && !authState.signingIn && ( +
+
SIWF Result
+
+ {JSON.stringify(signInResult, null, 2)} +
+
+ )} ); } diff --git a/src/hooks/useQuickAuth.ts b/src/hooks/useQuickAuth.ts deleted file mode 100644 index f14cac5..0000000 --- a/src/hooks/useQuickAuth.ts +++ /dev/null @@ -1,204 +0,0 @@ -'use client'; - -import { useState, useEffect, useCallback } from 'react'; -import { sdk } from '@farcaster/miniapp-sdk'; - -/** - * Represents the current authenticated user state - */ -interface AuthenticatedUser { - /** The user's Farcaster ID (FID) */ - fid: number; -} - -/** - * Possible authentication states for QuickAuth - */ -type QuickAuthStatus = 'loading' | 'authenticated' | 'unauthenticated'; - -/** - * Return type for the useQuickAuth hook - */ -interface UseQuickAuthReturn { - /** Current authenticated user data, or null if not authenticated */ - authenticatedUser: AuthenticatedUser | null; - /** Current authentication status */ - status: QuickAuthStatus; - /** Function to initiate the sign-in process using QuickAuth */ - signIn: () => Promise; - /** Function to sign out and clear the current authentication state */ - signOut: () => Promise; - /** Function to retrieve the current authentication token */ - getToken: () => Promise; -} - -/** - * Custom hook for managing QuickAuth authentication state - * - * This hook provides a complete authentication flow using Farcaster's QuickAuth: - * - Automatically checks for existing authentication on mount - * - Validates tokens with the server-side API - * - Manages authentication state in memory (no persistence) - * - Provides sign-in/sign-out functionality - * - * QuickAuth tokens are managed in memory only, so signing out of the Farcaster - * client will automatically sign the user out of this mini app as well. - * - * @returns {UseQuickAuthReturn} Object containing user state and authentication methods - * - * @example - * ```tsx - * const { authenticatedUser, status, signIn, signOut } = useQuickAuth(); - * - * if (status === 'loading') return
Loading...
; - * if (status === 'unauthenticated') return ; - * - * return ( - *
- *

Welcome, FID: {authenticatedUser?.fid}

- * - *
- * ); - * ``` - */ -export function useQuickAuth(): UseQuickAuthReturn { - // Current authenticated user data - const [authenticatedUser, setAuthenticatedUser] = useState(null); - // Current authentication status - const [status, setStatus] = useState('loading'); - - /** - * Validates a QuickAuth token with the server-side API - * - * @param {string} authToken - The JWT token to validate - * @returns {Promise} User data if valid, null otherwise - */ - const validateTokenWithServer = async (authToken: string): Promise => { - try { - const validationResponse = await fetch('/api/auth/validate', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token: authToken }), - }); - - if (validationResponse.ok) { - const responseData = await validationResponse.json(); - return responseData.user; - } - - return null; - } catch (error) { - console.error('Token validation failed:', error); - return null; - } - }; - - /** - * Checks for existing authentication token and validates it on component mount - * This runs automatically when the hook is first used - */ - useEffect(() => { - const checkExistingAuthentication = async () => { - try { - // Attempt to retrieve existing token from QuickAuth SDK - const { token } = await sdk.quickAuth.getToken(); - - if (token) { - // Validate the token with our server-side API - const validatedUserSession = await validateTokenWithServer(token); - - if (validatedUserSession) { - // Token is valid, set authenticated state - setAuthenticatedUser(validatedUserSession); - setStatus('authenticated'); - } else { - // Token is invalid or expired, clear authentication state - setStatus('unauthenticated'); - } - } else { - // No existing token found, user is not authenticated - setStatus('unauthenticated'); - } - } catch (error) { - console.error('Error checking existing authentication:', error); - setStatus('unauthenticated'); - } - }; - - checkExistingAuthentication(); - }, []); - - /** - * Initiates the QuickAuth sign-in process - * - * Uses sdk.quickAuth.getToken() to get a QuickAuth session token. - * If there is already a session token in memory that hasn't expired, - * it will be immediately returned, otherwise a fresh one will be acquired. - * - * @returns {Promise} True if sign-in was successful, false otherwise - */ - const signIn = useCallback(async (): Promise => { - try { - setStatus('loading'); - - // Get QuickAuth session token - const { token } = await sdk.quickAuth.getToken(); - - if (token) { - // Validate the token with our server-side API - const validatedUserSession = await validateTokenWithServer(token); - - if (validatedUserSession) { - // Authentication successful, update user state - setAuthenticatedUser(validatedUserSession); - setStatus('authenticated'); - return true; - } - } - - // Authentication failed, clear user state - setStatus('unauthenticated'); - return false; - } catch (error) { - console.error('Sign-in process failed:', error); - setStatus('unauthenticated'); - return false; - } - }, []); - - /** - * Signs out the current user and clears the authentication state - * - * Since QuickAuth tokens are managed in memory only, this simply clears - * the local user state. The actual token will be cleared when the - * user signs out of their Farcaster client. - */ - const signOut = useCallback(async (): Promise => { - // Clear local user state - setAuthenticatedUser(null); - setStatus('unauthenticated'); - }, []); - - /** - * Retrieves the current authentication token from QuickAuth - * - * @returns {Promise} The current auth token, or null if not authenticated - */ - const getToken = useCallback(async (): Promise => { - try { - const { token } = await sdk.quickAuth.getToken(); - return token; - } catch (error) { - console.error('Failed to retrieve authentication token:', error); - return null; - } - }, []); - - return { - authenticatedUser, - status, - signIn, - signOut, - getToken, - }; -} \ No newline at end of file