Solana authentication with supabase
Note: Probably needs one more iteration to be ready for production
Using nextjs 14 + shadcn ui components
- Wallet provider context:
Check out also:
// context/WalletProvider export const SolanaProvider: FC<{ children: ReactNode }> = ({ children }) => { const wallets = useMemo( () => [ new PhantomWalletAdapter(), new SolflareWalletAdapter(), ], [] ); const onError = useCallback( (error: WalletError) => { toast({ title: "Wallet Error", description: error.message, }); }, [] ); return ( <ConnectionProvider endpoint={process.env.NEXT_PUBLIC_RPC!}> <WalletProvider autoConnect wallets={wallets} onError={onError}> {children} </WalletProvider> </ConnectionProvider> ); };
// context/AuthProvider 'use client'; interface User { id: string; address: string; avatar_url: string | null; billing_address: any | null; email: string | null; full_name: string | null; last_auth: string | null; last_auth_status: string | null; nonce: string | null; payment_method: any | null; } interface AuthState { token: string | null; user: User | null; loading: boolean; connectedWallet: string | null; } type AuthAction = | { type: 'SIGN_IN'; token: string; user: User; connectedWallet: string } | { type: 'SIGN_OUT' }; const initialState: AuthState = { token: null, user: null, loading: true, connectedWallet: null, }; function authReducer(state: AuthState, action: AuthAction): AuthState { switch (action.type) { case 'SIGN_IN': return { ...state, token: action.token, user: action.user, loading: false, connectedWallet: action.connectedWallet, }; case 'SIGN_OUT': return { ...state, token: null, user: null, loading: false, connectedWallet: null, }; default: return state; } } const AuthContext = createContext<{ token: string | null; user: User | null; loading: boolean; connectedWallet: string | null; signIn: () => Promise<void>; signOut: () => Promise<void>; } | undefined>(undefined); export const AuthProvider = ({ children }: { children: ReactNode }) => { const [state, dispatch] = useReducer(authReducer, initialState); const { publicKey, signMessage, disconnect, wallet } = useWallet(); const cookies = useCookies(); const signIn = useCallback(async () => { if (!signMessage || !publicKey || state.user) return; try { const nonceResponse = await fetch('/api/auth/nonce', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ address: publicKey.toBase58() }), }); if (!nonceResponse.ok) throw new Error(`Failed to fetch nonce: ${nonceResponse.statusText}`); const { nonce } = await nonceResponse.json(); const message = new SignMessage({ publicKey: publicKey.toBase58(), statement: 'Sign in', nonce }); const data = new TextEncoder().encode(message.prepare()); const signature = await signMessage(data); const serializedSignature = bs58.encode(signature); const signInResponse = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: JSON.stringify(message), signature: serializedSignature }), }); if (!signInResponse.ok) throw new Error(`Failed to sign in: ${signInResponse.statusText}`); const { token, user } = await signInResponse.json(); cookies.set('token', token); const connectedWallet = wallet?.adapter.name || 'Unknown Wallet'; dispatch({ type: 'SIGN_IN', token, user, connectedWallet }); } catch (error: any) { await disconnect(); console.error("Sign in error:", error.message); } }, [publicKey, signMessage, wallet, state.user, cookies, disconnect]); const signOut = useCallback(async () => { if (!publicKey || !state.token) return; try { const logoutResponse = await fetch('/api/auth/logout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); if (!logoutResponse.ok) throw new Error(`Failed to logout: ${logoutResponse.statusText}`); await disconnect(); cookies.remove('token'); dispatch({ type: 'SIGN_OUT' }); } catch (error: any) { console.error("Sign out error:", error.message); } }, [publicKey, state.token, disconnect, cookies]); const getUser = useCallback(async (token: string) => { if (!token || !publicKey || state.user) return; try { const response = await fetch(`/api/user?address=${publicKey.toBase58()}`, { method: 'GET', headers: { 'Authorization': `Bearer ${token}` }, }); if (!response.ok) throw new Error(`Failed to fetch user info: ${response.statusText}`); const data = await response.json(); const connectedWallet = wallet?.adapter.name || 'Unknown Wallet'; dispatch({ type: 'SIGN_IN', token, user: data.user, connectedWallet }); } catch (error: any) { console.error("Failed to load user info:", error.message); await disconnect(); dispatch({ type: 'SIGN_OUT' }); } }, [publicKey, wallet, state.user]); const checkAuth = useCallback(async () => { try { const token = cookies.get('token'); if (token && publicKey) { await getUser(token); } else if (publicKey) { await signIn(); } } catch (error) { console.error('Error checking authentication:', error); dispatch({ type: 'SIGN_OUT' }); } }, [getUser, signIn, publicKey]); useEffect(() => { checkAuth(); }, [checkAuth]); const contextValue = useMemo(() => ({ ...state, signIn, signOut, }), [state, signIn, signOut]); return ( <AuthContext.Provider value={contextValue}> {children} </AuthContext.Provider> ); }; export const useAuth = () => { const context = useContext(AuthContext); if (!context) throw new Error('useAuth must be used within an AuthProvider'); return context; };
// app/layout export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { const cookies = getCookies(); const token = cookies.get('token'); return ( <html lang="en"> <body className={inter.className}> <ThemeProvider attribute="class" defaultTheme="system"> <SolanaProvider> <CookiesProvider> <AuthProvider initToken={token}> {children} <Toaster /> </AuthProvider> </CookiesProvider> </SolanaProvider> </ThemeProvider> </body> </html> ); }
// components/Wallet export default function Wallet() { const { signOut, token } = useAuth(); return ( <div className="border-t px-4 py-4"> {token ? ( <div className="flex flex-col items-center"> <Button onClick={signOut} className="w-full"> Disconnect Wallet </Button> </div> ) : ( <WalletModal /> )} </div> ); }
// components/WalletModal export default function WalletModal() { const { wallets, select } = useWallet(); const installedWallets = useMemo(() => { const installed: Wallet[] = []; for (const wallet of wallets) { if (wallet.readyState === WalletReadyState.Installed) { installed.push(wallet); } } return installed; }, [wallets]); return ( <Dialog> <DialogTrigger asChild> <Button className="w-full"> Connect Wallet </Button> </DialogTrigger> <DialogContent className="sm:max-w-[450px]"> <DialogHeader> <DialogTitle>Connect a Wallet</DialogTitle> <DialogDescription>Select a wallet to connect and start using our dApp.</DialogDescription> </DialogHeader> <div className="grid gap-4 py-2"> {installedWallets.length ? ( installedWallets.map(wallet => ( <Button key={wallet.adapter.name} className="rounded-lg border p-4 hover:bg-gray-100 dark:border-gray-800 dark:hover:bg-gray-800 cursor-pointer flex items-center justify-start" onClick={() => select(wallet.adapter.name)} > <Image src={wallet.adapter.icon} width={32} height={32} alt={`${wallet.adapter.name} Wallet`} /> <h1 className="ml-3 font-medium">{wallet.adapter.name}</h1> </Button> )) ) : ( <h1>You'll need a wallet on Solana to continue</h1> )} </div> </DialogContent> </Dialog> ); }
// lib/supabase // note define db type with its CLI: npx supabase gen types --lang=typescript --project-id PROJECT_ID --schema public > database.types.ts export const SupabaseAdapter = (supabase: SupabaseClient): Adapter => { return { getNonce: async (address: string) => { const { data, error } = await supabase .from('login_attempts') .select('nonce') .eq('address', address) .single(); if (error) console.error(error); return data?.nonce; }, getTLL: async (address: string) => { const { data, error } = await supabase .from('login_attempts') .select('ttl') .eq('address', address) .single(); if (error) console.error(error); return data?.ttl; }, saveAttempt: async (attempt) => { const { error } = await supabase .from('login_attempts') .upsert(attempt) .eq('address', attempt.address) .single(); if (error) console.error(error); }, generateToken: async (userId: string) => { const payload = { sub: userId, iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 60 * 60, }; const token = jwt.sign(payload, config.SUPABASE_JWT_SECRET); await supabaseAuthAdapter.setClaim(userId, 'userrole', 'USER'); return token; }, isAuthenticated: async (token: string) => { try { const { payload } = await jwtVerify(token, new TextEncoder().encode(config.SUPABASE_JWT_SECRET)); const { sub, exp } = payload; if (!sub) { console.error('Invalid token: missing UUID'); return false; } const currentTime = Math.floor(Date.now() / 1000); if (exp && currentTime > exp) { console.error('Token has expired'); return false; } const { data, error } = await supabase.rpc('get_claims', { uid: sub }); if (error || !data) { console.error('User is not authenticated: invalid success claim'); return false; } return true; } catch (error) { console.error('Token validation failed:', error); return false; } }, setClaim: async (uid: string, claim: string, value: string) => { const { data, error } = await supabase.rpc('set_claim', { uid, claim, value }); if (error) { console.error(error); return null; } return data; }, } }; export const supabase = createClient<Database>(config.SUPABASE_PROJECT_ID, config.SUPABASE_SERVICE_ROLE); export const supabaseAuthAdapter = SupabaseAdapter(supabase);
// app/api/auth/login export async function POST(req: NextRequest) { try { const { message, signature } = await req.json(); if (!message || !signature) { return NextResponse.json({ error: 'Message and signature are required' }, { status: 400 }); } const signMessage = new SignMessage(JSON.parse(message)); const validationResult = await signMessage.validate(signature); if (!validationResult) { return NextResponse.json({ error: 'Invalid signature' }, { status: 401 }); } const storedNonce = await supabaseAuthAdapter.getNonce(signMessage.publicKey); if (storedNonce !== signMessage.nonce) { return NextResponse.json({ error: 'Invalid nonce' }, { status: 401 }); } const address = signMessage.publicKey; let user = await supabase .from('users') .select('*') .eq('address', address) .single(); if (user.error && user.error.code !== 'PGRST116') { throw user.error; } else if (!user.data) { const { data: authUser, error: authError } = await supabase.auth.admin.createUser({ email: `${address}@email.com`, user_metadata: { address }, }); if (authError) throw authError; const newUser = await supabase .from('users') .update({ address, id: authUser.user.id }) .single(); if (newUser.error) throw newUser.error; user = newUser; } const token = supabaseAuthAdapter.generateToken(); await supabase .from('users') .update({ nonce: null, last_auth: new Date().toISOString(), last_auth_status: 'success', }) .eq('address', address); return NextResponse.json({ token, user: user.data }, { status: 200 }); } catch (error: any) { console.error('Error during login:', error); return NextResponse.json({ error: error.message || 'Login failed' }, { status: error.status || 500 }); } }
// app/api/auth/logout import { supabase } from '@/lib/supabase'; import { NextResponse } from 'next/server'; export async function POST() { const { error } = await supabase.auth.signOut(); if (error) { console.log({ error }); } const response = NextResponse.json({ message: 'Logged out' }); response.cookies.set('token', '', { maxAge: 0 }); return response; }
// app/api/auth/nonce import { supabaseAuthAdapter } from '@/lib/supabase'; import { NextRequest, NextResponse } from 'next/server'; import { v4 as uuid } from 'uuid'; export async function POST(req: NextRequest) { try { const { address } = await req.json(); if (!address) { return NextResponse.json({ error: 'Address is required' }, { status: 400 }); } const nonce = uuid(); const attempt = { address, nonce, ttl: (Math.floor(Date.now() / 1000) + 300).toString(), // 5 minutes TTL }; await supabaseAuthAdapter.saveAttempt(attempt); return NextResponse.json({ nonce }, { status: 200 }); } catch (error) { console.error('Error generating nonce:', error); return NextResponse.json({ error: 'Failed to generate nonce' }, { status: 500 }); } }
// lib/signMessage type SignMessageProps = { publicKey: string; nonce: string; statement: string; }; export class SignMessage { publicKey: string; nonce: string; statement: string; constructor({ publicKey, nonce, statement }: SignMessageProps) { this.publicKey = publicKey; this.nonce = nonce; this.statement = statement; } prepare() { return `${this.statement}${this.nonce}`; } async validate(signature: string) { const msg = this.prepare(); const signatureUint8 = bs58.decode(signature); const msgUint8 = new TextEncoder().encode(msg); const pubKeyUint8 = bs58.decode(this.publicKey); return nacl.sign.detached.verify(msgUint8, signatureUint8, pubKeyUint8); } }
// middleware.ts import { supabaseAuthAdapter } from '@/lib/supabase'; import { type NextRequest, NextResponse } from 'next/server'; export default async function middleware(request: NextRequest) { const token = request.cookies.get('token')?.value; if (!token) { return new NextResponse(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } try { const auth = await supabaseAuthAdapter.isAuthenticated(token); if (!auth) { console.error('Authentication error'); return new NextResponse(JSON.stringify({ error: 'Invalid or expired token' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } return NextResponse.next(); } catch (error: any) { console.error('Unexpected error during authentication:', error); return new NextResponse(JSON.stringify({ error: 'Internal Server Error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } export const config = { matcher: [ '/api/product/:path*', '/api/solana/:path*', '/api/user/:path*', ], };