laconic-deployer-frontend/docs/architecture/wallet_migration/3-phase-3-clerk-integration.md

22 KiB

Phase 3: Clerk Authentication Integration

Overview

Phase 3 completes the wallet integration by connecting it to Clerk authentication, implementing middleware protection, and creating a unified authentication flow. This phase eliminates the need for separate auth systems and provides a seamless experience.

Timeline

Duration: 2 weeks Dependencies: Phase 1 & 2 completion Team: Auth & Backend team

Architecture

graph TD
    A[Browser] --> B[Next.js Middleware]
    B --> C{Auth Check}
    
    C -->|No Auth| D[Clerk Sign-in]
    C -->|Has Auth| E[Protected Routes]
    
    D --> F[GitHub OAuth]
    D --> G[Wallet Auth]
    
    F --> H[Clerk Session]
    G --> H
    
    H --> I[User Metadata]
    I --> J[Wallet Address]
    
    E --> K[Wallet Operations]
    K --> L[API Routes]
    L --> M[Server Actions]

Migration Reference

These implementations adapt functionality from:

  • Wallet auth flow → from /repos/laconic-wallet-web/src/screens/AutoSignIn.tsx
  • Session management → from /repos/laconic-wallet-web/src/App.tsx session handling
  • Clerk integration → extends existing /apps/deploy-fe/src/middleware.ts

Security Considerations

This phase deals with authentication and sensitive wallet operations. Consider these critical security aspects:

  1. Session Management: Implement proper session handling using Clerk's secure mechanisms
  2. Wallet-Clerk Linking: Verify wallet ownership through cryptographic signatures before linking to a user account
  3. Authorization: Use proper authorization checks for all wallet operations
  4. Error States: Gracefully handle network failures and blockchain errors
  5. Rate Limiting: Implement rate limiting for sensitive operations like wallet linking

Step-by-Step Implementation

1. Extend Clerk User Metadata

Create a TypeScript interface for the extended user metadata in apps/deploy-fe/src/types/clerk.d.ts:

import { User } from '@clerk/nextjs/server'

declare module '@clerk/nextjs/server' {
  interface User {
    publicMetadata: {
      walletAddress?: string
      walletChainId?: string
      walletConnected?: boolean
    }
  }
}

2. Implement Clerk API Routes for Wallet Auth

Create apps/deploy-fe/src/app/api/clerk/wallet/link/route.ts:

import { auth, clerkClient } from '@clerk/nextjs/server'
import { validateSignature } from '@workspace/wallet-core'
import { NextResponse } from 'next/server'
import { z } from 'zod'

// Define request validation schema with Zod
const linkWalletSchema = z.object({
  address: z.string()
    .refine(addr => /^(0x[a-fA-F0-9]{40}|laconic[a-zA-Z0-9]{39,59})$/.test(addr), {
      message: "Invalid wallet address format"
    }),
  message: z.string().min(10),
  signature: z.string().min(1),
  chainId: z.string().min(1)
})

export async function POST(request: Request) {
  // Rate limiting - in production implement proper rate limiting middleware
  // This is a placeholder to show where it should be implemented
  
  const { userId } = await auth()
  
  if (!userId) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  try {
    const body = await request.json()
    
    // Validate request data
    const validationResult = linkWalletSchema.safeParse(body)
    if (!validationResult.success) {
      return NextResponse.json(
        { error: 'Invalid request data', details: validationResult.error.format() },
        { status: 400 }
      )
    }
    
    const { address, message, signature, chainId } = validationResult.data
    
    // Ensure the signed message actually contains the correct address
    // This prevents replay attacks where a valid signature for one address is used for another
    if (!message.includes(address)) {
      return NextResponse.json(
        { error: 'Address in message does not match provided address' },
        { status: 400 }
      )
    }
    
    // Validate signature using wallet-core
    const isValid = await validateSignature(message, signature)
    
    if (!isValid.success) {
      return NextResponse.json(
        { error: 'Invalid signature' },
        { status: 400 }
      )
    }
    
    // Update Clerk user metadata with wallet info
    await clerkClient.users.updateUser(userId, {
      publicMetadata: {
        walletAddress: address,
        walletChainId: chainId,
        walletConnected: true
      }
    })
    
    return NextResponse.json({ success: true })
  } catch (error) {
    console.error('Error linking wallet:', error)
    return NextResponse.json(
      { error: error instanceof Error ? error.message : 'Unknown error' },
      { status: 500 }
    )
  }
}

Create apps/deploy-fe/src/app/api/clerk/wallet/unlink/route.ts:

import { auth, clerkClient } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'

export async function POST(request: Request) {
  const { userId } = await auth()
  
  if (!userId) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  try {
    const user = await clerkClient.users.getUser(userId)
    
    // Remove wallet information from metadata
    await clerkClient.users.updateUser(userId, {
      publicMetadata: {
        ...user.publicMetadata,
        walletAddress: null,
        walletChainId: null,
        walletConnected: false
      }
    })
    
    return NextResponse.json({ success: true })
  } catch (error) {
    console.error('Error unlinking wallet:', error)
    return NextResponse.json(
      { error: error instanceof Error ? error.message : 'Unknown error' },
      { status: 500 }
    )
  }
}

3. Enhance Middleware with Wallet Verification

Update apps/deploy-fe/src/middleware.ts:

import { clerkMiddleware, createRouteMatcher, getAuth } from '@clerk/nextjs/server'
import { NextResponse } from 'next/server'

// Define routes that require wallet connection
const requiresWalletAuth = createRouteMatcher([
  '/wallet(.*)',
  '/projects(.*)',
  '/buy-prepaid-service(.*)'
])

// Define public routes that don't require any auth
const isPublicRoute = createRouteMatcher([
  '/sign-in(.*)',
  '/sign-up(.*)',
  '/api/github/webhook'
])

export default clerkMiddleware(async (auth, req) => {
  const { userId } = auth
  
  // Skip auth check for webhook endpoint
  if (req.nextUrl.pathname === '/api/github/webhook') {
    return NextResponse.next()
  }

  // For public routes, allow access
  if (isPublicRoute(req)) {
    return NextResponse.next()
  }
  
  // For all other routes, require authentication
  if (!userId) {
    return NextResponse.redirect(new URL('/sign-in', req.url))
  }
  
  // For wallet-required routes, check wallet connection
  if (requiresWalletAuth(req)) {
    const user = auth.user
    
    // If wallet not connected, redirect to wallet connection page
    if (!user?.publicMetadata?.walletConnected) {
      return NextResponse.redirect(new URL('/wallet/connect', req.url))
    }
  }
  
  return NextResponse.next()
})

export const config = {
  matcher: [
    // Skip Next.js internals and all static files
    '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
    // Always run for API routes
    '/(api|trpc)(.*)'
  ]
}

4. Create Wallet Connection Page

Create apps/deploy-fe/src/app/(web3-authenticated)/wallet/connect/page.tsx:

'use client'

import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useUser } from '@clerk/nextjs'
import { WalletConnectButton, useWalletUI } from '@workspace/ui/wallet'
import { Button } from '@workspace/ui/components/button'
import { PageWrapper } from '@/components/foundation'
import { createSiweMessage, signMessage } from '@workspace/wallet-core'
import { toast } from 'sonner'
import { ArrowLeft } from 'lucide-react'

export default function ConnectWalletPage() {
  const router = useRouter()
  const { isConnected, wallet } = useWalletUI()
  const { user, isLoaded } = useUser()
  const [isLinking, setIsLinking] = useState(false)
  
  // Check if user already has a linked wallet
  useEffect(() => {
    if (isLoaded && user?.publicMetadata?.walletConnected) {
      router.push('/')
    }
  }, [isLoaded, user, router])
  
  // Function to link wallet to Clerk user
  const linkWallet = async () => {
    if (!wallet?.address) return
    
    setIsLinking(true)
    try {
      // Create SIWE message
      const message = await createSiweMessage(wallet.address)
      
      // Sign message with wallet
      const signature = await signMessage({
        message,
        namespace: 'eip155',
        chainId: '1',
        accountId: 0
      })
      
      // Link wallet to Clerk user
      const response = await fetch('/api/clerk/wallet/link', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          address: wallet.address,
          message,
          signature,
          chainId: '1'
        })
      })
      
      if (response.ok) {
        toast.success('Wallet connected successfully')
        router.push('/')
      } else {
        const data = await response.json()
        throw new Error(data.error || 'Failed to link wallet')
      }
    } catch (error) {
      console.error('Error linking wallet:', error)
      toast.error(error instanceof Error ? error.message : 'Failed to link wallet')
    } finally {
      setIsLinking(false)
    }
  }
  
  return (
    <PageWrapper header={{ title: 'Connect Wallet' }}>
      <div className="max-w-md mx-auto text-center py-12">
        <h1 className="text-2xl font-bold mb-6">Connect Your Wallet</h1>
        <p className="mb-8 text-muted-foreground">
          Connect your wallet to access additional features like deployments
          and blockchain-related functionality.
        </p>
        
        <div className="space-y-6">
          {!isConnected ? (
            <WalletConnectButton size="lg" />
          ) : (
            <div className="space-y-4">
              <p className="font-medium">
                Wallet connected: {wallet?.address?.slice(0, 6)}...{wallet?.address?.slice(-4)}
              </p>
              <Button 
                onClick={linkWallet} 
                disabled={isLinking}
                size="lg"
              >
                {isLinking ? 'Linking...' : 'Link Wallet with Account'}
              </Button>
            </div>
          )}
          
          <Button variant="ghost" onClick={() => router.back()}>
            <ArrowLeft className="mr-2 h-4 w-4" />
            Go Back
          </Button>
        </div>
      </div>
    </PageWrapper>
  )
}

5. Update User Profile to Show Wallet Information

Create apps/deploy-fe/src/components/user-profile/WalletInfo.tsx:

'use client'

import React from 'react'
import { useUser } from '@clerk/nextjs'
import { Button } from '@workspace/ui/components/button'
import { WalletConnectButton } from '@workspace/ui/wallet'
import { toast } from 'sonner'

export function WalletInfo() {
  const { user, isLoaded } = useUser()
  const walletAddress = user?.publicMetadata?.walletAddress as string | undefined
  const walletConnected = user?.publicMetadata?.walletConnected as boolean
  
  const disconnectWallet = async () => {
    try {
      const response = await fetch('/api/clerk/wallet/unlink', {
        method: 'POST'
      })
      
      if (response.ok) {
        toast.success('Wallet disconnected')
        // Reload user to update metadata
        user?.reload()
      } else {
        const data = await response.json()
        throw new Error(data.error || 'Failed to disconnect wallet')
      }
    } catch (error) {
      console.error('Error disconnecting wallet:', error)
      toast.error(error instanceof Error ? error.message : 'Failed to disconnect wallet')
    }
  }
  
  if (!isLoaded) {
    return <div className="animate-pulse h-12 bg-gray-200 rounded" />
  }
  
  return (
    <div className="border rounded-lg p-4">
      <h3 className="text-lg font-medium mb-2">Wallet</h3>
      
      {walletConnected && walletAddress ? (
        <div className="space-y-4">
          <div className="flex justify-between items-center">
            <div>
              <p className="text-sm text-muted-foreground">Connected Address:</p>
              <p className="font-mono text-sm">
                {walletAddress.slice(0, 8)}...{walletAddress.slice(-6)}
              </p>
            </div>
            <Button variant="destructive" size="sm" onClick={disconnectWallet}>
              Disconnect
            </Button>
          </div>
        </div>
      ) : (
        <div className="text-center py-4">
          <p className="text-sm text-muted-foreground mb-4">
            No wallet connected to your account
          </p>
          <WalletConnectButton />
        </div>
      )}
    </div>
  )
}

6. Create Server Action to Check Wallet Auth

Create apps/deploy-fe/src/app/actions/wallet.ts:

'use server'

import { auth, clerkClient } from '@clerk/nextjs/server'
import { checkBalance } from '@workspace/wallet-core'

export async function checkWalletBalance(chainId: string, amount: string) {
  const { userId } = await auth()
  
  if (!userId) {
    throw new Error('Unauthorized')
  }
  
  const user = await clerkClient.users.getUser(userId)
  const walletAddress = user.publicMetadata.walletAddress as string | undefined
  
  if (!walletAddress) {
    throw new Error('No wallet connected')
  }
  
  // Use the wallet-core to check balance
  return checkBalance(chainId, walletAddress, amount)
}

export async function getWalletStatus() {
  const { userId } = await auth()
  
  if (!userId) {
    return { isConnected: false }
  }
  
  const user = await clerkClient.users.getUser(userId)
  const walletConnected = user.publicMetadata.walletConnected as boolean
  const walletAddress = user.publicMetadata.walletAddress as string | undefined
  
  return {
    isConnected: !!walletConnected,
    address: walletAddress
  }
}

7. Update Balance Checking Components

Replace apps/deploy-fe/src/components/projects/project/deployments/CheckBalanceWrapper.tsx:

'use client'

import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { checkWalletBalance } from '@/app/actions/wallet'
import { toast } from 'sonner'

interface CheckBalanceWrapperProps {
  children: React.ReactNode
  requiredAmount: string
  redirectTo?: string
}

export function CheckBalanceWrapper({
  children,
  requiredAmount,
  redirectTo = '/buy-prepaid-service'
}: CheckBalanceWrapperProps) {
  const router = useRouter()
  const [isChecking, setIsChecking] = useState(true)
  const [hasSufficientBalance, setHasSufficientBalance] = useState<boolean>()
  
  useEffect(() => {
    const checkBalance = async () => {
      try {
        setIsChecking(true)
        const chainId = process.env.NEXT_PUBLIC_LACONICD_CHAIN_ID || ''
        const result = await checkWalletBalance(chainId, requiredAmount)
        
        setHasSufficientBalance(result.hasEnoughBalance)
        
        if (!result.hasEnoughBalance) {
          toast.error('Insufficient balance for this operation')
          router.push(redirectTo)
        }
      } catch (error) {
        console.error('Error checking balance:', error)
        toast.error('Failed to check wallet balance')
      } finally {
        setIsChecking(false)
      }
    }
    
    checkBalance()
  }, [requiredAmount, redirectTo, router])
  
  if (isChecking) {
    return <div className="animate-pulse h-full w-full bg-gray-100 rounded opacity-50" />
  }
  
  if (hasSufficientBalance === false) {
    return null
  }
  
  return <>{children}</>
}

8. Create Wallet Status React Context

Create apps/deploy-fe/src/context/WalletStatusContext.tsx:

'use client'

import React, { createContext, useContext, useEffect, useState } from 'react'
import { useUser } from '@clerk/nextjs'

interface WalletStatus {
  isConnected: boolean
  address?: string
}

interface WalletStatusContextType {
  walletStatus: WalletStatus
  refreshWalletStatus: () => void
  isLoading: boolean
}

const WalletStatusContext = createContext<WalletStatusContextType | undefined>(undefined)

export function WalletStatusProvider({ children }: { children: React.ReactNode }) {
  const { user, isLoaded } = useUser()
  const [isLoading, setIsLoading] = useState(true)
  const [walletStatus, setWalletStatus] = useState<WalletStatus>({
    isConnected: false
  })
  
  const refreshWalletStatus = () => {
    if (isLoaded && user) {
      const walletConnected = user.publicMetadata.walletConnected as boolean
      const walletAddress = user.publicMetadata.walletAddress as string | undefined
      
      setWalletStatus({
        isConnected: !!walletConnected,
        address: walletAddress
      })
      setIsLoading(false)
    } else {
      setWalletStatus({ isConnected: false })
      setIsLoading(!isLoaded)
    }
  }
  
  useEffect(() => {
    refreshWalletStatus()
  }, [isLoaded, user])
  
  return (
    <WalletStatusContext.Provider value={{ walletStatus, refreshWalletStatus, isLoading }}>
      {children}
    </WalletStatusContext.Provider>
  )
}

export function useWalletStatus() {
  const context = useContext(WalletStatusContext)
  
  if (context === undefined) {
    throw new Error('useWalletStatus must be used within a WalletStatusProvider')
  }
  
  return context
}

9. Update Providers

Update apps/deploy-fe/src/components/providers/index.tsx:

'use client'

import React, { ReactNode } from 'react'
import { ThemeProvider } from 'next-themes'
import { WalletProvider } from '../wallet/WalletProvider'
import { WalletStatusProvider } from '@/context/WalletStatusContext'

export function Providers({ children }: { children: ReactNode }) {
  return (
    <ThemeProvider attribute="class" defaultTheme="light" enableSystem>
      <WalletStatusProvider>
        <WalletProvider>{children}</WalletProvider>
      </WalletStatusProvider>
    </ThemeProvider>
  )
}

10. Add Wallet Status to Application UI

Update apps/deploy-fe/src/components/foundation/top-navigation/TopNavigation.tsx:

'use client'

import React from 'react'
import { WalletConnectButton } from '@workspace/ui/wallet'
import { useWalletStatus } from '@/context/WalletStatusContext'
import { Button } from '@workspace/ui/components/button'
import Link from 'next/link'
import { Wallet } from 'lucide-react'

export function TopNavigation() {
  const { walletStatus, isLoading } = useWalletStatus()
  
  return (
    <header className="border-b sticky top-0 z-40 bg-background">
      <div className="container flex h-16 items-center px-4 sm:px-6">
        <nav className="flex-1 flex items-center justify-between">
          {/* Logo and other navigation items */}
          
          {/* Right side items */}
          <div className="flex items-center space-x-4">
            {isLoading ? (
              <div className="h-9 w-32 bg-gray-200 rounded animate-pulse" />
            ) : walletStatus.isConnected ? (
              <Button variant="outline" size="sm" asChild>
                <Link href="/wallet">
                  <Wallet className="mr-2 h-4 w-4" />
                  {walletStatus.address?.slice(0, 6)}...{walletStatus.address?.slice(-4)}
                </Link>
              </Button>
            ) : (
              <Button asChild>
                <Link href="/wallet/connect">
                  <Wallet className="mr-2 h-4 w-4" />
                  Connect Wallet
                </Link>
              </Button>
            )}
            {/* Other right-side items */}
          </div>
        </nav>
      </div>
    </header>
  )
}

Testing

  1. Run the frontend app:
cd apps/deploy-fe
pnpm dev
  1. Test the integration:
    • Sign in with Clerk (GitHub OAuth)
    • Connect and link wallet
    • Check wallet information in user profile
    • Test protected routes requiring wallet connection
    • Test balance checking functionality
    • Test wallet disconnection

Fallback Handling

To ensure robustness, implement these fallback mechanisms:

// Example fallback for chain disconnections
async function getBalanceWithFallback(address: string, chainId: string) {
  try {
    // Try primary RPC endpoint
    return await checkBalance(chainId, address, "1")
  } catch (error) {
    console.error("Primary RPC failed:", error)
    
    // Try fallback RPC
    try {
      const fallbackRpc = getFallbackRpcForChain(chainId)
      return await checkBalanceWithCustomRpc(fallbackRpc, address, "1")
    } catch (fallbackError) {
      console.error("Fallback RPC failed:", fallbackError)
      throw new Error("Unable to connect to blockchain nodes")
    }
  }
}

Integration Complete

At this point, the integration of the Laconic wallet within the Next.js application is complete. The system provides:

  1. Unified Authentication: GitHub OAuth through Clerk combined with wallet authentication
  2. Secure Middleware: Route protection based on auth status and wallet connection
  3. Clean Architecture: No iframe dependencies, using server actions and API routes
  4. Improved UX: Seamless integration with the existing UI components
  5. Robust Validation: Type-safe validation with Zod throughout the application
  6. Proper Error Handling: Comprehensive error states with fallback mechanisms

This completes all three phases of migrating the wallet from an iframe-based implementation to a native Next.js integration with Clerk auth.

Additional Resources