diff --git a/src/app/api/auth/session-signers/route.ts b/src/app/api/auth/session-signers/route.ts new file mode 100644 index 0000000..0d23d48 --- /dev/null +++ b/src/app/api/auth/session-signers/route.ts @@ -0,0 +1,99 @@ +import { NextResponse } from 'next/server'; +import { getServerSession } from 'next-auth'; +import { authOptions } from '~/auth'; +import { getNeynarClient } from '~/lib/neynar'; + +export async function GET(request: Request) { + try { + const session = await getServerSession(authOptions); + + if (!session?.user?.fid) { + return NextResponse.json( + { error: 'No authenticated session found' }, + { status: 401 } + ); + } + + const { searchParams } = new URL(request.url); + const message = searchParams.get('message'); + const signature = searchParams.get('signature'); + + if (!message || !signature) { + return NextResponse.json( + { error: 'Message and signature are required' }, + { status: 400 } + ); + } + + const client = getNeynarClient(); + const data = await client.fetchSigners({ message, signature }); + const signers = data.signers; + + // Fetch user data if signers exist + let user = null; + if (signers && signers.length > 0) { + try { + const userResponse = await fetch( + `${process.env.NEXTAUTH_URL}/api/users?fids=${signers[0].fid}` + ); + if (userResponse.ok) { + const userDataResponse = await userResponse.json(); + user = userDataResponse.users?.[0] || null; + } + } catch (error) { + console.error('Error fetching user data:', error); + } + } + + return NextResponse.json({ + signers, + user, + }); + } catch (error) { + console.error('Error in session-signers API:', error); + return NextResponse.json( + { error: 'Failed to fetch signers' }, + { status: 500 } + ); + } +} + +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 { message, signature, signers, user } = body; + + if (!message || !signature || !signers) { + return NextResponse.json( + { error: 'Message, signature, and signers are required' }, + { status: 400 } + ); + } + + // Since we can't directly modify the session token here, + // we'll return the data and let the client trigger a session update + // The client will need to call getSession() to refresh the session + + return NextResponse.json({ + success: true, + message: 'Session data prepared for update', + signers, + user, + }); + } catch (error) { + console.error('Error updating session signers:', error); + return NextResponse.json( + { error: 'Failed to update session' }, + { status: 500 } + ); + } +} 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/auth.ts b/src/auth.ts index a8e6ca1..c3345fb 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -4,16 +4,198 @@ import { createAppClient, viemConnector } from '@farcaster/auth-client'; declare module 'next-auth' { interface Session { - user: { + provider?: string; + user?: { fid: number; - provider?: string; + 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; - username?: 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; + }; } } @@ -127,20 +309,15 @@ export const authOptions: AuthOptions = { type: 'text', placeholder: '0', }, - username: { - label: 'Username', + signers: { + label: 'Signers', type: 'text', - placeholder: 'username', + placeholder: 'JSON string of signers', }, - displayName: { - label: 'Display Name', + user: { + label: 'User Data', type: 'text', - placeholder: 'Display Name', - }, - pfpUrl: { - label: 'Profile Picture URL', - type: 'text', - placeholder: 'https://...', + placeholder: 'JSON string of user data', }, }, async authorize(credentials) { @@ -182,13 +359,11 @@ export const authOptions: AuthOptions = { return { id: fid.toString(), - name: - credentials?.displayName || - credentials?.username || - `User ${fid}`, - image: credentials?.pfpUrl || null, provider: 'neynar', - username: credentials?.username || undefined, + 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); @@ -199,18 +374,27 @@ export const authOptions: AuthOptions = { ], callbacks: { session: async ({ session, token }) => { - if (session?.user) { - session.user.fid = parseInt(token.sub ?? ''); - // Add provider information to session - session.user.provider = token.provider as string; - session.user.username = token.username as string; + // 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.username = user.username; + token.signers = user.signers; + token.user = user.user; } return token; }, diff --git a/src/components/ui/NeynarAuthButton/index.tsx b/src/components/ui/NeynarAuthButton/index.tsx index 1fec0b1..a4d18a3 100644 --- a/src/components/ui/NeynarAuthButton/index.tsx +++ b/src/components/ui/NeynarAuthButton/index.tsx @@ -137,6 +137,36 @@ export function NeynarAuthButton() { } }, []); + // Helper function to update session with signers (backend flow only) + const updateSessionWithSigners = useCallback( + async ( + signers: StoredAuthState['signers'], + user: StoredAuthState['user'] + ) => { + 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); + } + } catch (error) { + console.error('❌ Error updating session with signers:', error); + } + }, + [useBackendFlow, message, signature, nonce] + ); + // Helper function to fetch user data from Neynar API const fetchUserData = useCallback( async (fid: number): Promise => { @@ -201,33 +231,51 @@ export function NeynarAuthButton() { try { setSignersLoading(true); - const response = await fetch( - `/api/auth/signers?message=${encodeURIComponent( - message - )}&signature=${signature}` - ); + const endpoint = useBackendFlow + ? `/api/auth/session-signers?message=${encodeURIComponent( + message + )}&signature=${signature}` + : `/api/auth/signers?message=${encodeURIComponent( + message + )}&signature=${signature}`; + const response = await fetch(endpoint); const signerData = await response.json(); if (response.ok) { - let user: StoredAuthState['user'] | null = null; + 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)); + await updateSessionWithSigners(signerData.signers, user); + } + return signerData.signers; + } else { + // For frontend flow, store in localStorage + let user: StoredAuthState['user'] | null = null; - if (signerData.signers && signerData.signers.length > 0) { - user = await fetchUserData(signerData.signers[0].fid); + if (signerData.signers && signerData.signers.length > 0) { + const fetchedUser = (await fetchUserData( + signerData.signers[0].fid + )) as StoredAuthState['user']; + user = fetchedUser; + } + + // Store signers in localStorage, preserving existing auth data + const existingAuth = getItem(STORAGE_KEY); + const updatedState: StoredAuthState = { + ...existingAuth, + isAuthenticated: !!user, + signers: signerData.signers || [], + user, + }; + setItem(STORAGE_KEY, updatedState); + setStoredAuth(updatedState); + + return signerData.signers; } - - // Store signers in localStorage, preserving existing auth data - const existingAuth = getItem(STORAGE_KEY); - const updatedState: StoredAuthState = { - ...existingAuth, - isAuthenticated: !!user, - signers: signerData.signers || [], - user, - }; - setItem(STORAGE_KEY, updatedState); - setStoredAuth(updatedState); - - return signerData.signers; } else { console.error('❌ Failed to fetch signers'); // throw new Error('Failed to fetch signers'); @@ -239,7 +287,7 @@ export function NeynarAuthButton() { setSignersLoading(false); } }, - [] + [useBackendFlow, fetchUserData, updateSessionWithSigners] ); // Helper function to poll signer status @@ -305,26 +353,34 @@ export function NeynarAuthButton() { generateNonce(); }, []); - // Load stored auth state on mount + // Load stored auth state on mount (only for frontend flow) useEffect(() => { - const stored = getItem(STORAGE_KEY); - if (stored && stored.isAuthenticated) { - setStoredAuth(stored); + if (!useBackendFlow) { + const stored = getItem(STORAGE_KEY); + if (stored && stored.isAuthenticated) { + setStoredAuth(stored); + } } - }, []); + }, [useBackendFlow]); // Success callback - this is critical! - const onSuccessCallback = useCallback((res: unknown) => { - const existingAuth = getItem(STORAGE_KEY); - const authState: StoredAuthState = { - isAuthenticated: true, - user: res as StoredAuthState['user'], - signers: existingAuth?.signers || [], // Preserve existing signers - }; - setItem(STORAGE_KEY, authState); - setStoredAuth(authState); - // setShowDialog(false); - }, []); + const onSuccessCallback = useCallback( + (res: unknown) => { + if (!useBackendFlow) { + // Only handle localStorage for frontend flow + const existingAuth = getItem(STORAGE_KEY); + const authState: StoredAuthState = { + isAuthenticated: true, + user: res 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] + ); // Error callback const onErrorCallback = useCallback((error?: Error | null) => { @@ -368,12 +424,6 @@ export function NeynarAuthButton() { if (message && signature) { const handleSignerFlow = async () => { try { - // // Ensure we have message and signature - // if (!message || !signature) { - // console.error('❌ Missing message or signature'); - // return; - // } - // Step 1: Change to loading state setDialogStep('loading'); setSignersLoading(true); @@ -403,10 +453,13 @@ export function NeynarAuthButton() { setDebugState('Setting signer approval URL...'); setSignerApprovalUrl(signedKeyData.signer_approval_url); setSignersLoading(false); // Stop loading, show QR code - if ( - context?.client?.platformType === 'mobile' && - context?.client?.clientFid === FARCASTER_FID - ) { + // Check if we're in a mobile context + const clientContext = context?.client as Record; + const isMobileContext = + clientContext?.platformType === 'mobile' && + clientContext?.clientFid === FARCASTER_FID; + + if (isMobileContext) { setDebugState('Opening mobile app...'); setShowDialog(false); await sdk.actions.openUrl( @@ -418,16 +471,11 @@ export function NeynarAuthButton() { } else { setDebugState( 'Opening access dialog...' + - ` ${context?.client?.platformType}` + - ` ${context?.client?.clientFid}` + ` ${clientContext?.platformType}` + + ` ${clientContext?.clientFid}` ); setDialogStep('access'); setShowDialog(true); - setDebugState( - 'Opening access dialog...2' + - ` ${dialogStep}` + - ` ${showDialog}` - ); } // Step 4: Start polling for signer approval @@ -514,15 +562,17 @@ export function NeynarAuthButton() { if (useBackendFlow) { // Only sign out from NextAuth if the current session is from Neynar provider - if (session?.user?.provider === 'neynar') { + if (session?.provider === 'neynar') { await backendSignOut({ redirect: false }); } } else { + // Frontend flow sign out frontendSignOut(); + removeItem(STORAGE_KEY); + setStoredAuth(null); } - removeItem(STORAGE_KEY); - setStoredAuth(null); + // Common cleanup for both flows setShowDialog(false); setDialogStep('signin'); setSignerApprovalUrl(null); @@ -543,15 +593,27 @@ export function NeynarAuthButton() { } }, [useBackendFlow, frontendSignOut, pollingInterval, session]); - // The key fix: match the original library's authentication logic exactly - const authenticated = - ((isSuccess && validSignature) || storedAuth?.isAuthenticated) && - !!(storedAuth?.signers && storedAuth.signers.length > 0); - const userData = { - fid: storedAuth?.user?.fid, - username: storedAuth?.user?.username || '', - pfpUrl: storedAuth?.user?.pfp_url || '', - }; + const authenticated = useBackendFlow + ? !!( + 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: session?.user?.fid, + username: session?.user?.username || '', + pfpUrl: session?.user?.pfp_url || '', + } + : { + fid: storedAuth?.user?.fid, + username: storedAuth?.user?.username || '', + pfpUrl: storedAuth?.user?.pfp_url || '', + }; // Show loading state while nonce is being fetched or signers are loading if (!nonce || signersLoading) { @@ -589,12 +651,15 @@ export function NeynarAuthButton() { ) : ( <> - {debugState || 'Sign in with Neynar'} + Sign in with Neynar )} )} +

LocalStorage state

+ {window && JSON.stringify(window.localStorage.getItem(STORAGE_KEY))} + {/* Unified Auth Dialog */} { ({ ...prev, signingOut: true })); // Only sign out if the current session is from Farcaster provider - if (session?.user?.provider === 'farcaster') { + if (session?.provider === 'farcaster') { await signOut({ redirect: false }); } setSignInResult(undefined); @@ -118,18 +118,16 @@ export function SignIn() { return ( <> {/* Authentication Buttons */} - {(status !== 'authenticated' || - session?.user?.provider !== 'farcaster') && ( + {(status !== 'authenticated' || session?.provider !== 'farcaster') && ( )} - {status === 'authenticated' && - session?.user?.provider === 'farcaster' && ( - - )} + {status === 'authenticated' && session?.provider === 'farcaster' && ( + + )} {/* Session Information */} {session && (