feat: reapply quickauth changes conditionally

This commit is contained in:
veganbeef
2025-07-16 09:16:58 -07:00
parent 8eabbd3ba1
commit e61bc88aaa
8 changed files with 588 additions and 202 deletions

View File

@@ -0,0 +1,46 @@
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 },
);
}
}

View File

@@ -1,6 +1,5 @@
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";
@@ -15,7 +14,19 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
const session = await getSession()
// Only get session if sponsored signer is enabled or seed phrase is provided
const sponsorSigner = process.env.SPONSOR_SIGNER === 'true';
const hasSeedPhrase = !!process.env.SEED_PHRASE;
let session = null;
if (sponsorSigner || hasSeedPhrase) {
try {
const { getSession } = await import("~/auth");
session = await getSession();
} catch (error) {
console.warn('Failed to get session:', error);
}
}
return (
<html lang="en">

View File

@@ -24,18 +24,36 @@ export function Providers({
}) {
const solanaEndpoint =
process.env.SOLANA_RPC_ENDPOINT || 'https://solana-rpc.publicnode.com';
// Only wrap with SessionProvider if session is provided
if (session) {
return (
<SessionProvider session={session}>
<WagmiProvider>
<MiniAppProvider
analyticsEnabled={ANALYTICS_ENABLED}
backButtonEnabled={true}
>
<SafeFarcasterSolanaProvider endpoint={solanaEndpoint}>
<AuthKitProvider config={{}}>{children}</AuthKitProvider>
</SafeFarcasterSolanaProvider>
</MiniAppProvider>
</WagmiProvider>
</SessionProvider>
);
}
// Return without SessionProvider if no session
return (
<SessionProvider session={session}>
<WagmiProvider>
<MiniAppProvider
analyticsEnabled={ANALYTICS_ENABLED}
backButtonEnabled={true}
>
<SafeFarcasterSolanaProvider endpoint={solanaEndpoint}>
<AuthKitProvider config={{}}>{children}</AuthKitProvider>
</SafeFarcasterSolanaProvider>
</MiniAppProvider>
</WagmiProvider>
</SessionProvider>
<WagmiProvider>
<MiniAppProvider
analyticsEnabled={ANALYTICS_ENABLED}
backButtonEnabled={true}
>
<SafeFarcasterSolanaProvider endpoint={solanaEndpoint}>
<AuthKitProvider config={{}}>{children}</AuthKitProvider>
</SafeFarcasterSolanaProvider>
</MiniAppProvider>
</WagmiProvider>
);
}

View File

@@ -1,13 +1,23 @@
'use client';
import { useCallback, useState } from 'react';
import { useCallback, useState, type ComponentType } from 'react';
import { useMiniApp } from '@neynar/react';
import { ShareButton } from '../Share';
import { Button } from '../Button';
import { SignIn } from '../wallet/SignIn';
import { type Haptics } from '@farcaster/miniapp-sdk';
import { APP_URL } from '~/lib/constants';
import { NeynarAuthButton } from '../NeynarAuthButton/index';
// Optional import for NeynarAuthButton - may not exist in all templates
let NeynarAuthButton: ComponentType | null = null;
try {
const module = require('../NeynarAuthButton/index');
NeynarAuthButton = module.NeynarAuthButton;
} catch (error) {
// Component doesn't exist, that's okay
console.log('NeynarAuthButton not available in this template');
}
/**
* ActionsTab component handles mini app actions like sharing, notifications, and haptic feedback.
@@ -140,7 +150,7 @@ export function ActionsTab() {
<SignIn />
{/* Neynar Authentication */}
<NeynarAuthButton />
{NeynarAuthButton && <NeynarAuthButton />}
{/* Mini app actions */}
<Button

View File

@@ -1,22 +1,20 @@
'use client';
import { useCallback, useState } from "react";
import { signIn, signOut, getCsrfToken } from "next-auth/react";
import sdk, { SignIn as SignInCore } from "@farcaster/miniapp-sdk";
import { useSession } from "next-auth/react";
import { Button } from "../Button";
import { useCallback, useState } from 'react';
import { SignIn as SignInCore } from '@farcaster/miniapp-sdk';
import { useQuickAuth } from '~/hooks/useQuickAuth';
import { Button } from '../Button';
/**
* SignIn component handles Farcaster authentication using Sign-In with Farcaster (SIWF).
* SignIn component handles Farcaster authentication using QuickAuth.
*
* This component provides a complete authentication flow for Farcaster users:
* - Generates nonces for secure authentication
* - Handles the SIWF flow using the Farcaster SDK
* - Manages NextAuth session state
* - Uses the built-in QuickAuth functionality from the Farcaster SDK
* - Manages authentication state in memory (no persistence)
* - Provides sign-out functionality
* - Displays authentication status and results
*
* The component integrates with both the Farcaster Frame SDK and NextAuth
* The component integrates with the Farcaster Frame SDK and QuickAuth
* to provide seamless authentication within mini apps.
*
* @example
@@ -36,52 +34,32 @@ export function SignIn() {
signingIn: false,
signingOut: false,
});
const [signInResult, setSignInResult] = useState<SignInCore.SignInResult>();
const [signInFailure, setSignInFailure] = useState<string>();
// --- Hooks ---
const { data: session, status } = useSession();
const { authenticatedUser, status, signIn, signOut } = useQuickAuth();
// --- Handlers ---
/**
* Generates a nonce for the sign-in process.
* Handles the sign-in process using QuickAuth.
*
* This function retrieves a CSRF token from NextAuth to use as a nonce
* for the SIWF authentication flow. The nonce ensures the authentication
* request is fresh and prevents replay attacks.
*
* @returns Promise<string> - The generated nonce token
* @throws Error if unable to generate nonce
*/
const getNonce = useCallback(async () => {
const nonce = await getCsrfToken();
if (!nonce) throw new Error('Unable to generate nonce');
return nonce;
}, []);
/**
* Handles the sign-in process using Farcaster SDK.
*
* This function orchestrates the complete SIWF flow:
* 1. Generates a nonce for security
* 2. Calls the Farcaster SDK to initiate sign-in
* 3. Submits the result to NextAuth for session management
* 4. Handles various error conditions including user rejection
* This function uses the built-in QuickAuth functionality:
* 1. Gets a token from QuickAuth (handles SIWF flow automatically)
* 2. Validates the token with our server
* 3. Updates the session state
*
* @returns Promise<void>
*/
const handleSignIn = useCallback(async () => {
try {
setAuthState((prev) => ({ ...prev, signingIn: true }));
setAuthState(prev => ({ ...prev, signingIn: true }));
setSignInFailure(undefined);
const nonce = await getNonce();
const result = await sdk.actions.signIn({ nonce });
setSignInResult(result);
await signIn('farcaster', {
message: result.message,
signature: result.signature,
redirect: false,
});
const success = await signIn();
if (!success) {
setSignInFailure('Authentication failed');
}
} catch (e) {
if (e instanceof SignInCore.RejectedByUser) {
setSignInFailure('Rejected by user');
@@ -89,52 +67,49 @@ export function SignIn() {
}
setSignInFailure('Unknown error');
} finally {
setAuthState((prev) => ({ ...prev, signingIn: false }));
setAuthState(prev => ({ ...prev, signingIn: false }));
}
}, [getNonce]);
}, [signIn]);
/**
* Handles the sign-out process.
*
* This function clears the NextAuth session only if the current session
* is using the Farcaster provider, and resets the local sign-in result state.
* This function clears the QuickAuth session and resets the local state.
*
* @returns Promise<void>
*/
const handleSignOut = useCallback(async () => {
try {
setAuthState((prev) => ({ ...prev, signingOut: true }));
// Only sign out if the current session is from Farcaster provider
if (session?.provider === 'farcaster') {
await signOut({ redirect: false });
}
setSignInResult(undefined);
setAuthState(prev => ({ ...prev, signingOut: true }));
await signOut();
} finally {
setAuthState((prev) => ({ ...prev, signingOut: false }));
setAuthState(prev => ({ ...prev, signingOut: false }));
}
}, [session]);
}, [signOut]);
// --- Render ---
return (
<>
{/* Authentication Buttons */}
{(status !== 'authenticated' || session?.provider !== 'farcaster') && (
{status !== 'authenticated' && (
<Button onClick={handleSignIn} disabled={authState.signingIn}>
Sign In with Farcaster
</Button>
)}
{status === 'authenticated' && session?.provider === 'farcaster' && (
{status === 'authenticated' && (
<Button onClick={handleSignOut} disabled={authState.signingOut}>
Sign out
</Button>
)}
{/* Session Information */}
{session && (
{authenticatedUser && (
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono">
<div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">Session</div>
<div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">
Authenticated User
</div>
<div className="whitespace-pre text-gray-700 dark:text-gray-200">
{JSON.stringify(session, null, 2)}
{JSON.stringify(authenticatedUser, null, 2)}
</div>
</div>
)}
@@ -142,20 +117,14 @@ export function SignIn() {
{/* Error Display */}
{signInFailure && !authState.signingIn && (
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono">
<div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">SIWF Result</div>
<div className="whitespace-pre text-gray-700 dark:text-gray-200">{signInFailure}</div>
</div>
)}
{/* Success Result Display */}
{signInResult && !authState.signingIn && (
<div className="my-2 p-2 text-xs overflow-x-scroll bg-gray-100 dark:bg-gray-900 rounded-lg font-mono">
<div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">SIWF Result</div>
<div className="font-semibold text-gray-500 dark:text-gray-300 mb-1">
Authentication Error
</div>
<div className="whitespace-pre text-gray-700 dark:text-gray-200">
{JSON.stringify(signInResult, null, 2)}
{signInFailure}
</div>
</div>
)}
</>
);
}
}

207
src/hooks/useQuickAuth.ts Normal file
View File

@@ -0,0 +1,207 @@
'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<boolean>;
/** Function to sign out and clear the current authentication state */
signOut: () => Promise<void>;
/** Function to retrieve the current authentication token */
getToken: () => Promise<string | null>;
}
/**
* 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 <div>Loading...</div>;
* if (status === 'unauthenticated') return <button onClick={signIn}>Sign In</button>;
*
* return (
* <div>
* <p>Welcome, FID: {authenticatedUser?.fid}</p>
* <button onClick={signOut}>Sign Out</button>
* </div>
* );
* ```
*/
export function useQuickAuth(): UseQuickAuthReturn {
// Current authenticated user data
const [authenticatedUser, setAuthenticatedUser] =
useState<AuthenticatedUser | null>(null);
// Current authentication status
const [status, setStatus] = useState<QuickAuthStatus>('loading');
/**
* Validates a QuickAuth token with the server-side API
*
* @param {string} authToken - The JWT token to validate
* @returns {Promise<AuthenticatedUser | null>} User data if valid, null otherwise
*/
const validateTokenWithServer = async (
authToken: string,
): Promise<AuthenticatedUser | null> => {
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<boolean>} True if sign-in was successful, false otherwise
*/
const signIn = useCallback(async (): Promise<boolean> => {
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<void> => {
// Clear local user state
setAuthenticatedUser(null);
setStatus('unauthenticated');
}, []);
/**
* Retrieves the current authentication token from QuickAuth
*
* @returns {Promise<string | null>} The current auth token, or null if not authenticated
*/
const getToken = useCallback(async (): Promise<string | null> => {
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,
};
}