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:
- Session Management: Implement proper session handling using Clerk's secure mechanisms
- Wallet-Clerk Linking: Verify wallet ownership through cryptographic signatures before linking to a user account
- Authorization: Use proper authorization checks for all wallet operations
- Error States: Gracefully handle network failures and blockchain errors
- 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
- Run the frontend app:
cd apps/deploy-fe
pnpm dev
- 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:
- Unified Authentication: GitHub OAuth through Clerk combined with wallet authentication
- Secure Middleware: Route protection based on auth status and wallet connection
- Clean Architecture: No iframe dependencies, using server actions and API routes
- Improved UX: Seamless integration with the existing UI components
- Robust Validation: Type-safe validation with Zod throughout the application
- 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
- Clerk Webhook Documentation - For keeping external systems in sync
- Next.js Middleware Documentation - For advanced route protection
- Ethers.js Documentation - For Ethereum wallet functionality
- CosmJS Documentation - For Cosmos wallet functionality