diff --git a/bin/init.js b/bin/init.js index d37b406..2c662ff 100644 --- a/bin/init.js +++ b/bin/init.js @@ -1,12 +1,12 @@ #!/usr/bin/env node -import { execSync } from 'child_process'; -import crypto from 'crypto'; -import fs from 'fs'; -import { dirname } from 'path'; -import path from 'path'; -import { fileURLToPath } from 'url'; import inquirer from 'inquirer'; +import { fileURLToPath } from 'url'; +import { dirname } from 'path'; +import { execSync } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import crypto from 'crypto'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -47,7 +47,7 @@ async function queryNeynarApp(apiKey) { } try { const response = await fetch( - 'https://api.neynar.com/portal/app_by_api_key?starter_kit=true', + `https://api.neynar.com/portal/app_by_api_key?starter_kit=true`, { headers: { 'x-api-key': apiKey, @@ -444,7 +444,7 @@ export async function init( // Update package.json console.log('\nUpdating package.json...'); const packageJsonPath = path.join(projectPath, 'package.json'); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + let packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); packageJson.name = finalProjectName; packageJson.version = '0.1.0'; @@ -465,6 +465,7 @@ export async function init( '@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', @@ -476,7 +477,6 @@ export async function init( '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', @@ -488,6 +488,7 @@ export async function init( }; packageJson.devDependencies = { + '@types/inquirer': '^9.0.8', '@types/node': '^20', '@types/react': '^19', '@types/react-dom': '^19', @@ -499,8 +500,8 @@ export async function init( 'pino-pretty': '^13.0.0', postcss: '^8', tailwindcss: '^3.4.1', - typescript: '^5', 'ts-node': '^10.9.2', + typescript: '^5', }; // Add Neynar SDK if selected diff --git a/package.json b/package.json index 0a8e2e7..2dd62d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@neynar/create-farcaster-mini-app", - "version": "1.6.3", + "version": "1.7.2", "type": "module", "private": false, "access": "public", diff --git a/scripts/deploy.ts b/scripts/deploy.ts index 13494a3..390d131 100755 --- a/scripts/deploy.ts +++ b/scripts/deploy.ts @@ -5,7 +5,6 @@ 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'; @@ -132,7 +131,7 @@ async function checkRequiredEnvVars(): Promise { process.env.SPONSOR_SIGNER = sponsorSigner.toString(); - if (storeSeedPhrase) { + if (process.env.SEED_PHRASE) { fs.appendFileSync( '.env.local', `\nSPONSOR_SIGNER="${sponsorSigner}"`, @@ -299,17 +298,26 @@ async function setVercelEnvVarSDK( } // Get existing environment variables - const existingVars = await vercelClient.projects.getEnvironmentVariables({ + const existingVars = await vercelClient.projects.filterProjectEnvs({ idOrName: projectId, }); - const existingVar = existingVars.envs?.find( + // 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) => env.key === key && env.target?.includes('production'), ); - if (existingVar) { + if (existingVar && existingVar.id) { // Update existing variable - await vercelClient.projects.editEnvironmentVariable({ + await vercelClient.projects.editProjectEnv({ idOrName: projectId, id: existingVar.id, requestBody: { @@ -320,7 +328,7 @@ async function setVercelEnvVarSDK( console.log(`✅ Updated environment variable: ${key}`); } else { // Create new variable - await vercelClient.projects.createEnvironmentVariable({ + await vercelClient.projects.createProjectEnv({ idOrName: projectId, requestBody: { key: key, @@ -458,7 +466,7 @@ async function waitForDeployment( while (Date.now() - startTime < maxWaitTime) { try { - const deployments = await vercelClient?.deployments.list({ + const deployments = await vercelClient?.deployments.getDeployments({ projectId: projectId, limit: 1, }); @@ -594,12 +602,17 @@ async function deployToVercel(useGitHub = false): Promise { if (vercelClient) { try { - const project = await vercelClient.projects.get({ - idOrName: projectId, - }); - projectName = project.name; - domain = `${projectName}.vercel.app`; - console.log('🌐 Using project name for domain:', domain); + 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'); + } } catch (error: unknown) { if (error instanceof Error) { console.warn( @@ -653,12 +666,7 @@ 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 && { @@ -763,7 +771,6 @@ 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 90a8879..d491e88 100755 --- a/scripts/dev.js +++ b/scripts/dev.js @@ -1,9 +1,9 @@ +import localtunnel from 'localtunnel'; import { spawn } from 'child_process'; import { createServer } from 'net'; +import dotenv from 'dotenv'; import path from 'path'; import { fileURLToPath } from 'url'; -import dotenv from 'dotenv'; -import localtunnel from 'localtunnel'; // Load environment variables dotenv.config({ path: '.env.local' }); @@ -96,7 +96,7 @@ async function startDev() { ? `1. Run: netstat -ano | findstr :${port}\n` + '2. Note the PID (Process ID) from the output\n' + '3. Run: taskkill /PID /F\n' - : 'On macOS/Linux, run:\nnpm run cleanup\n') + + : `On macOS/Linux, run:\nnpm run cleanup\n`) + '\nThen try running this command again.', ); process.exit(1); @@ -158,11 +158,7 @@ async function startDev() { nextDev = spawn(nextBin, ['dev', '-p', port.toString()], { stdio: 'inherit', - env: { - ...process.env, - NEXT_PUBLIC_URL: miniAppUrl, - NEXTAUTH_URL: miniAppUrl, - }, + env: { ...process.env, NEXT_PUBLIC_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 deleted file mode 100644 index 6b57f22..0000000 --- a/src/app/api/auth/[...nextauth]/route.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 34491d5..0000000 --- a/src/app/api/auth/update-session/route.ts +++ /dev/null @@ -1,46 +0,0 @@ -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 new file mode 100644 index 0000000..70256f8 --- /dev/null +++ b/src/app/api/auth/validate/route.ts @@ -0,0 +1,52 @@ +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 69cf628..75ff103 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next'; + import '~/app/globals.css'; import { Providers } from '~/app/providers'; -import { getSession } from '~/auth'; import { APP_NAME, APP_DESCRIPTION } from '~/lib/constants'; export const metadata: Metadata = { @@ -14,12 +14,10 @@ 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 5970d14..bbc7d56 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -1,10 +1,7 @@ 'use client'; import dynamic from 'next/dynamic'; -import { AuthKitProvider } from '@farcaster/auth-kit'; import { MiniAppProvider } from '@neynar/react'; -import type { Session } from 'next-auth'; -import { SessionProvider } from 'next-auth/react'; import { SafeFarcasterSolanaProvider } from '~/components/providers/SafeFarcasterSolanaProvider'; import { ANALYTICS_ENABLED } from '~/lib/constants'; @@ -15,27 +12,19 @@ const WagmiProvider = dynamic( }, ); -export function Providers({ - session, - children, -}: { - session: Session | null; - children: React.ReactNode; -}) { +export function Providers({ children }: { 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 a790041..5733ae5 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -1,439 +1,10 @@ -import { createAppClient, viemConnector } from '@farcaster/auth-client'; -import { AuthOptions, getServerSession } from 'next-auth'; -import CredentialsProvider from 'next-auth/providers/credentials'; +import { sdk } from '@farcaster/miniapp-sdk'; -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; - }[]; - } - - 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 QuickAuth from the SDK +export const quickAuth = sdk.quickAuth; +// Helper function to get session (for server-side compatibility) export const getSession = async () => { - try { - return await getServerSession(authOptions); - } catch (error) { - console.error('Error getting server session:', error); - return null; - } + // For QuickAuth, sessions are managed by the SDK + return null; }; diff --git a/src/components/ui/NeynarAuthButton/ProfileButton.tsx b/src/components/ui/NeynarAuthButton/ProfileButton.tsx index 2f1a098..3febdc2 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?.fid}`; - const pfpUrl = userData?.pfpUrl ?? 'https://farcaster.xyz/avatar.png'; + 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'; return (
diff --git a/src/components/ui/NeynarAuthButton/index.tsx b/src/components/ui/NeynarAuthButton/index.tsx index 9c2d288..4fa057c 100644 --- a/src/components/ui/NeynarAuthButton/index.tsx +++ b/src/components/ui/NeynarAuthButton/index.tsx @@ -1,20 +1,13 @@ 'use client'; -import '@farcaster/auth-kit/styles.css'; import { useCallback, useEffect, useState, useRef } from 'react'; -import { useSignIn, UseSignInData } from '@farcaster/auth-kit'; -import sdk, { SignIn as SignInCore } from '@farcaster/frame-sdk'; -import { useMiniApp } from '@neynar/react'; -import { - signIn as backendSignIn, - signOut as backendSignOut, - useSession, -} from 'next-auth/react'; -import { Button } from '~/components/ui/Button'; -import { AuthDialog } from '~/components/ui/NeynarAuthButton/AuthDialog'; -import { ProfileButton } from '~/components/ui/NeynarAuthButton/ProfileButton'; -import { getItem, removeItem, setItem } from '~/lib/localStorage'; import { cn } from '~/lib/utils'; +import { Button } from '~/components/ui/Button'; +import { ProfileButton } from '~/components/ui/NeynarAuthButton/ProfileButton'; +import { AuthDialog } from '~/components/ui/NeynarAuthButton/AuthDialog'; +import { useMiniApp } from '@neynar/react'; +import sdk, { SignIn as SignInCore } from '@farcaster/frame-sdk'; +import { useQuickAuth } from '~/hooks/useQuickAuth'; type User = { fid: number; @@ -24,7 +17,6 @@ type User = { // Add other user properties as needed }; -const STORAGE_KEY = 'neynar_authenticated_user'; const FARCASTER_FID = 9152; interface StoredAuthState { @@ -98,7 +90,12 @@ export function NeynarAuthButton() { const [storedAuth, setStoredAuth] = useState(null); const [signersLoading, setSignersLoading] = useState(false); const { context } = useMiniApp(); - const { data: session } = useSession(); + const { + authenticatedUser: quickAuthUser, + signIn: quickAuthSignIn, + signOut: quickAuthSignOut, + } = useQuickAuth(); + // New state for unified dialog flow const [showDialog, setShowDialog] = useState(false); const [dialogStep, setDialogStep] = useState<'signin' | 'access' | 'loading'>( @@ -114,6 +111,10 @@ 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; @@ -146,25 +147,15 @@ export function NeynarAuthButton() { if (!useBackendFlow) return; try { - // 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); + // For backend flow, use QuickAuth to sign in + if (signers && signers.length > 0) { + await quickAuthSignIn(); } } catch (error) { console.error('❌ Error updating session with signers:', error); } }, - [useBackendFlow, message, signature, nonce], + [useBackendFlow, quickAuthSignIn], ); // Helper function to fetch user data from Neynar API @@ -246,14 +237,18 @@ export function NeynarAuthButton() { if (useBackendFlow) { // For backend flow, update session with signers if (signerData.signers && signerData.signers.length > 0) { - const user = - signerData.user || - (await fetchUserData(signerData.signers[0].fid)); + // 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']; + } await updateSessionWithSigners(signerData.signers, user); } return signerData.signers; } else { - // For frontend flow, store in localStorage + // For frontend flow, store in memory only let user: StoredAuthState['user'] | null = null; if (signerData.signers && signerData.signers.length > 0) { @@ -263,13 +258,12 @@ export function NeynarAuthButton() { user = fetchedUser; } - // Store signers in localStorage, preserving existing auth data + // Store signers in memory only const updatedState: StoredAuthState = { isAuthenticated: !!user, signers: signerData.signers || [], user, }; - setItem(STORAGE_KEY, updatedState); setStoredAuth(updatedState); return signerData.signers; @@ -384,78 +378,105 @@ export function NeynarAuthButton() { generateNonce(); }, []); - // Load stored auth state on mount (only for frontend flow) - useEffect(() => { - if (!useBackendFlow) { - const stored = getItem(STORAGE_KEY); - if (stored && stored.isAuthenticated) { - setStoredAuth(stored); + // Backend flow using QuickAuth + 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 }); + + setMessage(result.message); + setSignature(result.signature); + // Use QuickAuth to sign in + const signInResult = await quickAuthSignIn(); + // Fetch user profile after sign in + if (quickAuthUser?.fid) { + const user = await fetchUserData(quickAuthUser.fid); + setBackendUserProfile({ + username: user?.username || '', + pfpUrl: user?.pfp_url || '', + }); + } + } catch (e) { + if (e instanceof SignInCore.RejectedByUser) { + console.log('â„šī¸ Sign-in rejected by user'); + } else { + console.error('❌ Backend sign-in error:', e); } } - }, [useBackendFlow]); - - // 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); - } - // For backend flow, the session will be handled by NextAuth - }, - [useBackendFlow, fetchUserData], - ); - - // Error callback - const onErrorCallback = useCallback((error?: Error | null) => { - console.error('❌ Sign in error:', error); - }, []); - - const signInState = useSignIn({ - nonce: nonce || undefined, - onSuccess: onSuccessCallback, - onError: onErrorCallback, - }); - - const { - signIn: frontendSignIn, - signOut: frontendSignOut, - connect, - reconnect, - isSuccess, - isError, - error, - channelToken, - url, - data, - validSignature, - } = signInState; + }, [nonce, quickAuthSignIn, quickAuthUser, fetchUserData]); + // Fetch user profile when quickAuthUser.fid changes (for backend flow) useEffect(() => { - setMessage(data?.message || null); - setSignature(data?.signature || null); + if (useBackendFlow && quickAuthUser?.fid) { + (async () => { + const user = await fetchUserData(quickAuthUser.fid); + setBackendUserProfile({ + username: user?.username || '', + pfpUrl: user?.pfp_url || '', + }); + })(); + } + }, [useBackendFlow, quickAuthUser?.fid, fetchUserData]); - // Reset the signer flow flag when message/signature change - if (data?.message && data?.signature) { + 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); + } + } finally { + setSignersLoading(false); + } + }, [nonce]); + + const handleSignOut = useCallback(async () => { + try { + setSignersLoading(true); + + if (useBackendFlow) { + // Use QuickAuth sign out + await quickAuthSignOut(); + } else { + // Frontend flow sign out + 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); } - }, [data?.message, data?.signature]); - - // Connect for frontend flow when nonce is available - useEffect(() => { - if (!useBackendFlow && nonce && !channelToken) { - connect(); - } - }, [useBackendFlow, nonce, channelToken, connect]); + }, [useBackendFlow, pollingInterval, quickAuthSignOut]); // Handle fetching signers after successful authentication useEffect(() => { @@ -538,103 +559,16 @@ 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.error('â„šī¸ 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 - ? !!( - session?.provider === 'neynar' && - session?.user?.fid && - session?.signers && - session.signers.length > 0 - ) - : ((isSuccess && validSignature) || storedAuth?.isAuthenticated) && + ? !!quickAuthUser?.fid + : storedAuth?.isAuthenticated && !!(storedAuth?.signers && storedAuth.signers.length > 0); const userData = useBackendFlow ? { - fid: session?.user?.fid, - username: session?.user?.username || '', - pfpUrl: session?.user?.pfp_url || '', + fid: quickAuthUser?.fid, + username: backendUserProfile.username ?? '', + pfpUrl: backendUserProfile.pfpUrl ?? '', } : { fid: storedAuth?.user?.fid, @@ -663,18 +597,17 @@ export function NeynarAuthButton() { ) : ( )} - {status === 'authenticated' && session?.provider === 'farcaster' && ( + {status === 'authenticated' && ( )} {/* Session Information */} - {session && ( + {authenticatedUser && (
- Session + Authenticated User
- {JSON.stringify(session, null, 2)} + {JSON.stringify(authenticatedUser, null, 2)}
)} @@ -145,25 +118,13 @@ export function SignIn() { {signInFailure && !authState.signingIn && (
- SIWF Result + Authentication Error
{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 new file mode 100644 index 0000000..f14cac5 --- /dev/null +++ b/src/hooks/useQuickAuth.ts @@ -0,0 +1,204 @@ +'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