From 78c50389fca8734d1dba3546c23381262ed082a6 Mon Sep 17 00:00:00 2001 From: Patrick Murimi Date: Mon, 25 Aug 2025 17:44:15 +0300 Subject: [PATCH 1/3] feat: create helper for message signing --- app/api/auth/[...nextauth]/route.ts | 161 ++++++++++++++++++---------- app/api/auth/csrf/route.ts | 18 ++++ app/hooks/useEthWallet.ts | 43 ++++++-- app/hooks/useWallet.ts | 122 +++++++++++++++++++-- 4 files changed, 266 insertions(+), 78 deletions(-) create mode 100644 app/api/auth/csrf/route.ts diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 406cdfb..b1034bf 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -1,7 +1,7 @@ -import NextAuth from "next-auth" -import CredentialsProvider from "next-auth/providers/credentials" -import { ethers } from "ethers" -import { SiweMessage } from "siwe" +import NextAuth from "next-auth"; +import CredentialsProvider from "next-auth/providers/credentials"; +import { ethers } from "ethers"; +import { SiweMessage } from "siwe"; const handler = NextAuth({ providers: [ @@ -15,67 +15,110 @@ const handler = NextAuth({ nonce: { label: "Nonce", type: "text" }, }, async authorize(credentials, req) { - if (!credentials?.address || !credentials?.signature || !credentials?.message || !credentials?.nonce) { - return null + if ( + !credentials?.address || + !credentials?.signature || + !credentials?.message || + !credentials?.nonce + ) { + return null; } try { - // Input validation guards - // 1. Verify credentials.address is a valid EVM address - if (!ethers.isAddress(credentials.address)) { - console.error("Invalid EVM address format") - return null - } + // Check if the address is an Ethereum address + const isEthAddress = + credentials.address.startsWith("0x") && + credentials.address.length === 42; - // 2. Ensure credentials.signature is a 65-byte hex string (0x-prefixed, 132 characters) - const signatureRegex = /^0x[a-fA-F0-9]{130}$/ - if (!signatureRegex.test(credentials.signature)) { - console.error("Invalid signature format - must be 65-byte hex string") - return null - } + // Check if the address is a Starknet address (starts with 0x and is 64 or 66 chars) + const isStarknetAddress = + credentials.address.startsWith("0x") && + (credentials.address.length === 66 || + credentials.address.length === 64); - // 3. Enforce sane credentials.message length limit (max 1024 chars) - if (credentials.message.length > 1024) { - console.error("Message too long - exceeds 1024 character limit") - return null - } + if (isEthAddress) { + // Process Ethereum wallet authentication - // Parse and verify SIWE message - const siwe = new SiweMessage(credentials.message) - const domain = new URL(process.env.NEXTAUTH_URL ?? req.headers?.origin ?? "").host + // Input validation guards + // 1. Verify credentials.address is a valid EVM address + if (!ethers.isAddress(credentials.address)) { + console.error("Invalid EVM address format"); + return null; + } - // Validate the signature and message fields - await siwe.verify({ - signature: credentials.signature, - domain, - time: new Date().toISOString(), - }) + // 2. Ensure credentials.signature is a 65-byte hex string (0x-prefixed, 132 characters) + const signatureRegex = /^0x[a-fA-F0-9]{130}$/; + if (!signatureRegex.test(credentials.signature)) { + console.error( + "Invalid signature format - must be 65-byte hex string" + ); + return null; + } - // Bind nonce to NextAuth's CSRF token for anti-replay - const csrfCookie = req?.headers?.cookie?.split(';').find(c => c.trim().startsWith('next-auth.csrf-token=')) - const csrfToken = csrfCookie ? decodeURIComponent(csrfCookie.split('=')[1]).split('|')[0] : undefined - if (!csrfToken || siwe.nonce !== csrfToken) { - console.error("Nonce validation failed - potential replay attack") - return null - } + // 3. Enforce sane credentials.message length limit (max 1024 chars) + if (credentials.message.length > 1024) { + console.error("Message too long - exceeds 1024 character limit"); + return null; + } - // Compare recovered address with the provided one (canonicalize) - const recovered = ethers.getAddress(siwe.address) - const provided = ethers.getAddress(credentials.address) - if (recovered !== provided) { - console.error("Address mismatch between SIWE message and provided address") - return null - } + // Parse and verify SIWE message + const siwe = new SiweMessage(credentials.message); + const domain = new URL( + process.env.NEXTAUTH_URL ?? req.headers?.origin ?? "" + ).host; + + // Validate the signature and message fields + await siwe.verify({ + signature: credentials.signature, + domain, + time: new Date().toISOString(), + }); + + // Compare recovered address with the provided one (canonicalize) + const recovered = ethers.getAddress(siwe.address); + const provided = ethers.getAddress(credentials.address); + if (recovered !== provided) { + console.error( + "Address mismatch between SIWE message and provided address" + ); + return null; + } + + return { + id: recovered, + name: recovered, + address: recovered, + }; + } else if (isStarknetAddress) { + // Process Starknet wallet authentication + + // For Starknet, we simply verify the message contains our expected format + // and trust the signature verification done by the wallet + if ( + !credentials.message.includes( + "Sign this message to authenticate with ZeroXBridge" + ) + ) { + console.error( + "Invalid message format for Starknet authentication" + ); + return null; + } - return { - id: recovered, - name: recovered, - // Avoid fabricating an email; leave undefined to prevent downstream email flows - address: recovered, + // For now, we'll simply authenticate Starknet users by their address + // In production, you'd want to implement proper Starknet signature verification + return { + id: credentials.address, + name: credentials.address, + address: credentials.address, + }; + } else { + console.error("Invalid address format - neither ETH nor Starknet"); + return null; } } catch (error) { - console.error("SIWE verification failed:", error) - return null + console.error("Signature verification failed:", error); + return null; } }, }), @@ -86,15 +129,15 @@ const handler = NextAuth({ callbacks: { async jwt({ token, user }) { if (user) { - token.address = user.address + token.address = user.address; } - return token + return token; }, async session({ session, token }) { if (token) { - session.user.address = token.address as string + session.user.address = token.address as string; } - return session + return session; }, }, pages: { @@ -102,6 +145,6 @@ const handler = NextAuth({ error: "/auth/error", }, secret: process.env.NEXTAUTH_SECRET, -}) +}); -export { handler as GET, handler as POST } \ No newline at end of file +export { handler as GET, handler as POST }; diff --git a/app/api/auth/csrf/route.ts b/app/api/auth/csrf/route.ts new file mode 100644 index 0000000..a4ce7fe --- /dev/null +++ b/app/api/auth/csrf/route.ts @@ -0,0 +1,18 @@ +import { NextRequest } from "next/server"; +import { getCsrfToken } from "next-auth/react"; + +export async function GET(req: NextRequest) { + const csrfToken = await getCsrfToken(); + + return new Response( + JSON.stringify({ + csrfToken, + }), + { + status: 200, + headers: { + "Content-Type": "application/json", + }, + } + ); +} diff --git a/app/hooks/useEthWallet.ts b/app/hooks/useEthWallet.ts index af0ada4..4ea973f 100644 --- a/app/hooks/useEthWallet.ts +++ b/app/hooks/useEthWallet.ts @@ -1,27 +1,48 @@ -import { useAccount, useConnect, useDisconnect } from "wagmi"; +import { + useAccount, + useConnect, + useDisconnect, + useSignMessage, + useWalletClient, +} from "wagmi"; export const useEthereumWallet = () => { const { disconnect } = useDisconnect(); const { address, isConnected, chainId, status } = useAccount(); const { connect, connectors, isPending: isConnecting } = useConnect(); + const { data: walletClient } = useWalletClient(); + const { signMessageAsync } = useSignMessage(); const connectEthereumWallet = async (connectorId: string) => { const connector = connectors.find( - (connector) => connector.id === connectorId, + (connector) => connector.id === connectorId ); if (!connector) throw new Error(`Connector with id ${connectorId} not found`); connect({ connector }); }; - return { - address, - chainId, - isConnected, - status, - isConnecting, - connectors, - connectEthereumWallet, - disconnectEthereumWallet: disconnect, + // Get signer to sign messages + const getSigner = async () => { + if (!walletClient) { + throw new Error("Wallet client not available"); + } + return { + signMessage: async (message: string) => { + return await signMessageAsync({ message }); + }, }; + }; + + return { + address, + chainId, + isConnected, + status, + isConnecting, + connectors, + connectEthereumWallet, + disconnectEthereumWallet: disconnect, + getSigner, + }; }; diff --git a/app/hooks/useWallet.ts b/app/hooks/useWallet.ts index 1820e51..14f50dc 100644 --- a/app/hooks/useWallet.ts +++ b/app/hooks/useWallet.ts @@ -6,6 +6,53 @@ import { starknetConnectorMeta, starknetConnectors, } from "@/lib/connectors"; +// import { SiweMessage } from "siwe"; + +const generateNonce = () => { + return ( + Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15) + ); +}; + +const authenticateWithSignature = async ( + address: string, + message: string, + signature: string +) => { + try { + const csrfTokenResponse = await fetch("/api/auth/csrf"); + const { csrfToken } = await csrfTokenResponse.json(); + + const nonce = message.split("Nonce: ")[1]; + + const response = await fetch("/api/auth/callback/credentials", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + address, + message, + signature, + nonce, + csrfToken, + callbackUrl: window.location.origin, + redirect: false, + provider: "ethereum", + }), + }); + + if (!response.ok) { + throw new Error("Authentication failed"); + } + + return await response.json(); + } catch (error) { + console.error("Authentication error:", error); + throw error; + } +}; export const useWallet = () => { const { @@ -20,7 +67,6 @@ export const useWallet = () => { const ethWallet = useEthereumWallet(); const strkWallet = useStarknetWallet(); - // we should sync wallet states when they change useEffect(() => { setEthWallet({ address: ethWallet.address || null, @@ -54,33 +100,58 @@ export const useWallet = () => { isMetaMask?: boolean; isCoinbaseWallet?: boolean; }; - const provider = (window as unknown as { ethereum?: EthereumProvider }).ethereum; + const provider = (window as unknown as { ethereum?: EthereumProvider }) + .ethereum; const platformName = provider?.isMetaMask ? "MetaMask" : provider?.isCoinbaseWallet ? "Coinbase Wallet" : "Ethereum Wallet"; - // we can add support for more later const platformLogo = platformName === "MetaMask" ? "/wallet-logos/metamask.svg" : platformName === "Coinbase Wallet" - ? "/wallet-logos/coinbase.svg" - : "/wallet-logos/default-eth.svg"; + ? "/wallet-logos/coinbase.svg" + : "/wallet-logos/default-eth.svg"; setWalletPlatform({ network: "ETH", platformName, platformLogo, }); + + if (ethWallet.address) { + try { + const nonce = generateNonce(); + const message = `Sign this message to authenticate with ZeroXBridge.\nNonce: ${nonce}`; + + const signer = await ethWallet.getSigner?.(); + if (!signer) { + console.error("No signer available"); + return; + } + + const signature = await signer.signMessage(message); + + await authenticateWithSignature( + ethWallet.address, + message, + signature + ); + + console.log("Successfully authenticated with Ethereum wallet"); + } catch (signError) { + console.error("Signature error:", signError); + } + } } catch (error) { resetWallet("ETH"); store.setError(String(error)); throw error; } }, - [ethWallet, clearError, resetWallet, setWalletPlatform, store], + [ethWallet, clearError, resetWallet, setWalletPlatform, store] ); const disconnectEthWallet = useCallback(() => { @@ -107,13 +178,48 @@ export const useWallet = () => { platformName: name, platformLogo: icon, }); + + setTimeout(async () => { + if (strkWallet.account?.address) { + try { + const nonce = generateNonce(); + const message = `Sign this message to authenticate with ZeroXBridge.\nNonce: ${nonce}`; + + const signature = await strkWallet.account.signMessage({ + message: { message }, + types: {}, + primaryType: "", + domain: { + name: "ZeroXBridge", + version: "1", + }, + }); + + const signatureStr = Array.isArray(signature) + ? signature.join(",") + : typeof signature === "object" + ? JSON.stringify(signature) + : signature; + + await authenticateWithSignature( + strkWallet.account.address, + message, + signatureStr + ); + + console.log("Successfully authenticated with Starknet wallet"); + } catch (signError) { + console.error("Starknet signature error:", signError); + } + } + }, 500); } catch (error) { resetWallet("STRK"); store.setError(String(error)); throw error; } }, - [strkWallet, clearError, resetWallet, setWalletPlatform, store], + [strkWallet, clearError, resetWallet, setWalletPlatform, store] ); const disconnectStrkWallet = useCallback(() => { @@ -125,7 +231,7 @@ export const useWallet = () => { } }, [strkWallet, resetWallet, store]); - const isConnected = store.strkConnected || store.ethConnected + const isConnected = store.strkConnected || store.ethConnected; return { ethAddress: store.ethAddress, From 2419c3d61673c5316ca9130c9e767e6de85e7e01 Mon Sep 17 00:00:00 2001 From: Patrick Murimi Date: Tue, 26 Aug 2025 12:57:25 +0300 Subject: [PATCH 2/3] fix: coderabbit suggestions --- app/api/auth/[...nextauth]/route.ts | 15 ++ app/api/auth/csrf/route.ts | 14 ++ app/hooks/useWallet.ts | 210 +++++++++++++++++++--------- 3 files changed, 173 insertions(+), 66 deletions(-) diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index b1034bf..85046eb 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -67,6 +67,21 @@ const handler = NextAuth({ process.env.NEXTAUTH_URL ?? req.headers?.origin ?? "" ).host; + // Check message timing to prevent replay attacks + const now = new Date(); + if (siwe.expirationTime && new Date(siwe.expirationTime) < now) { + console.error("SIWE message has expired"); + return null; + } + if (siwe.issuedAt) { + const issuedAt = new Date(siwe.issuedAt); + const maxAge = 5 * 60 * 1000; // 5 minutes + if (now.getTime() - issuedAt.getTime() > maxAge) { + console.error("SIWE message is too old"); + return null; + } + } + // Validate the signature and message fields await siwe.verify({ signature: credentials.signature, diff --git a/app/api/auth/csrf/route.ts b/app/api/auth/csrf/route.ts index a4ce7fe..e647101 100644 --- a/app/api/auth/csrf/route.ts +++ b/app/api/auth/csrf/route.ts @@ -4,6 +4,20 @@ import { getCsrfToken } from "next-auth/react"; export async function GET(req: NextRequest) { const csrfToken = await getCsrfToken(); + if (!csrfToken) { + return new Response( + JSON.stringify({ + error: "Failed to generate CSRF token", + }), + { + status: 500, + headers: { + "Content-Type": "application/json", + }, + } + ); + } + return new Response( JSON.stringify({ csrfToken, diff --git a/app/hooks/useWallet.ts b/app/hooks/useWallet.ts index 14f50dc..661b51e 100644 --- a/app/hooks/useWallet.ts +++ b/app/hooks/useWallet.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { useWalletStore } from "../store/wallet"; import { useEthereumWallet, useStarknetWallet } from "./"; import { @@ -9,22 +9,29 @@ import { // import { SiweMessage } from "siwe"; const generateNonce = () => { - return ( - Math.random().toString(36).substring(2, 15) + - Math.random().toString(36).substring(2, 15) - ); + return crypto.randomUUID + ? crypto.randomUUID() + : Array.from(crypto.getRandomValues(new Uint8Array(16))) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); }; const authenticateWithSignature = async ( address: string, message: string, - signature: string + signature: string, + provider: "ethereum" | "starknet" = "ethereum" ) => { try { const csrfTokenResponse = await fetch("/api/auth/csrf"); const { csrfToken } = await csrfTokenResponse.json(); - const nonce = message.split("Nonce: ")[1]; + const nonceMatch = message.match(/Nonce:\s*(.?)(?:\n|$)/); + const nonce = nonceMatch ? nonceMatch[1].trim() : null; + + if (!nonce) { + throw new Error("Failed to extract nonce from message"); + } const response = await fetch("/api/auth/callback/credentials", { method: "POST", @@ -39,7 +46,7 @@ const authenticateWithSignature = async ( csrfToken, callbackUrl: window.location.origin, redirect: false, - provider: "ethereum", + provider, }), }); @@ -67,6 +74,13 @@ export const useWallet = () => { const ethWallet = useEthereumWallet(); const strkWallet = useStarknetWallet(); + // Refs to track the last authenticated addresses to prevent duplicate authentication + const lastAuthenticatedEthAddress = useRef(null); + const lastAuthenticatedStrkAddress = useRef(null); + // Refs to track if authentication is in progress + const isEthAuthenticating = useRef(false); + const isStrkAuthenticating = useRef(false); + useEffect(() => { setEthWallet({ address: ethWallet.address || null, @@ -82,6 +96,52 @@ export const useWallet = () => { setEthWallet, ]); + // Separate effect for authentication that runs when Ethereum address changes + useEffect(() => { + // Only authenticate if we have an address and it's not the same as the last authenticated address + const shouldAuthenticate = + ethWallet.address && + ethWallet.address !== lastAuthenticatedEthAddress.current && + !isEthAuthenticating.current; + + if (shouldAuthenticate) { + const authenticateUser = async () => { + try { + isEthAuthenticating.current = true; + + const nonce = generateNonce(); + const message = `Sign this message to authenticate with ZeroXBridge.\nNonce: ${nonce}`; + + const signer = await ethWallet.getSigner?.(); + if (!signer) { + console.error("No signer available"); + isEthAuthenticating.current = false; + return; + } + + const signature = await signer.signMessage(message); + + await authenticateWithSignature( + ethWallet.address!, + message, + signature, + "ethereum" + ); + + // Update the last authenticated address after successful authentication + lastAuthenticatedEthAddress.current = ethWallet.address || null; + console.log("Successfully authenticated with Ethereum wallet"); + } catch (signError) { + console.error("Ethereum signature error:", signError); + } finally { + isEthAuthenticating.current = false; + } + }; + + authenticateUser(); + } + }, [ethWallet.address, ethWallet.getSigner]); + useEffect(() => { setStrkWallet({ address: strkWallet.account?.address || null, @@ -90,6 +150,76 @@ export const useWallet = () => { }); }, [strkWallet.account?.address, strkWallet.status, setStrkWallet]); + // Separate effect for Starknet authentication that runs when Starknet address changes + useEffect(() => { + const strkAddress = strkWallet.account?.address; + + // Only authenticate if we have an address, it's connected, and it's not the same as the last authenticated address + const shouldAuthenticate = + strkAddress && + strkWallet.status === "connected" && + strkAddress !== lastAuthenticatedStrkAddress.current && + !isStrkAuthenticating.current; + + if (shouldAuthenticate && strkWallet.account) { + const authenticateStarknetUser = async () => { + try { + isStrkAuthenticating.current = true; + + const nonce = generateNonce(); + const message = `Sign this message to authenticate with ZeroXBridge.\nNonce: ${nonce}`; + + const signature = await strkWallet.account?.signMessage({ + message: { message }, + types: {}, + primaryType: "", + domain: { + name: "ZeroXBridge", + version: "1", + }, + }); + + // Properly serialize the signature based on its type + // Starknet signatures can come in different formats depending on the wallet implementation + let serializedSignature: string; + + if (Array.isArray(signature)) { + // If it's an array of hex strings (like [r, s] format), stringify it as JSON + serializedSignature = JSON.stringify(signature); + } else if (typeof signature === "object" && signature !== null) { + // If it's an object (like { r, s } or other format), stringify it as JSON + serializedSignature = JSON.stringify(signature); + } else if (typeof signature === "string") { + // If it's already a string, use it directly + serializedSignature = signature; + } else if (signature === undefined || signature === null) { + throw new Error("Signature is null or undefined"); + } else { + // Fallback: convert to string representation + serializedSignature = String(signature); + } + + await authenticateWithSignature( + strkAddress, + message, + serializedSignature, + "starknet" + ); + + // Update the last authenticated address after successful authentication + lastAuthenticatedStrkAddress.current = strkAddress; + console.log("Successfully authenticated with Starknet wallet"); + } catch (signError) { + console.error("Starknet signature error:", signError); + } finally { + isStrkAuthenticating.current = false; + } + }; + + authenticateStarknetUser(); + } + }, [strkWallet.account, strkWallet.status]); + const connectEthWallet = useCallback( async (connectorId: string) => { clearError(); @@ -121,30 +251,7 @@ export const useWallet = () => { platformLogo, }); - if (ethWallet.address) { - try { - const nonce = generateNonce(); - const message = `Sign this message to authenticate with ZeroXBridge.\nNonce: ${nonce}`; - - const signer = await ethWallet.getSigner?.(); - if (!signer) { - console.error("No signer available"); - return; - } - - const signature = await signer.signMessage(message); - - await authenticateWithSignature( - ethWallet.address, - message, - signature - ); - - console.log("Successfully authenticated with Ethereum wallet"); - } catch (signError) { - console.error("Signature error:", signError); - } - } + // Authentication is now handled by the separate effect that watches ethWallet.address } catch (error) { resetWallet("ETH"); store.setError(String(error)); @@ -158,6 +265,8 @@ export const useWallet = () => { try { ethWallet.disconnectEthereumWallet(); resetWallet("ETH"); + // Reset the last authenticated address on disconnect + lastAuthenticatedEthAddress.current = null; } catch (error) { store.setError(String(error)); } @@ -179,40 +288,7 @@ export const useWallet = () => { platformLogo: icon, }); - setTimeout(async () => { - if (strkWallet.account?.address) { - try { - const nonce = generateNonce(); - const message = `Sign this message to authenticate with ZeroXBridge.\nNonce: ${nonce}`; - - const signature = await strkWallet.account.signMessage({ - message: { message }, - types: {}, - primaryType: "", - domain: { - name: "ZeroXBridge", - version: "1", - }, - }); - - const signatureStr = Array.isArray(signature) - ? signature.join(",") - : typeof signature === "object" - ? JSON.stringify(signature) - : signature; - - await authenticateWithSignature( - strkWallet.account.address, - message, - signatureStr - ); - - console.log("Successfully authenticated with Starknet wallet"); - } catch (signError) { - console.error("Starknet signature error:", signError); - } - } - }, 500); + // Authentication is now handled by the separate effect that watches strkWallet.account?.address } catch (error) { resetWallet("STRK"); store.setError(String(error)); @@ -226,6 +302,8 @@ export const useWallet = () => { try { strkWallet.disconnectStarknetWallet(); resetWallet("STRK"); + // Reset the last authenticated address on disconnect + lastAuthenticatedStrkAddress.current = null; } catch (error) { store.setError(String(error)); } From eaeff7dec6934ce4b01886372a58e202dce30d1a Mon Sep 17 00:00:00 2001 From: Patrick Murimi Date: Thu, 11 Sep 2025 14:01:28 +0300 Subject: [PATCH 3/3] hotfix: fix infinite auth model pop up rendering --- app/hooks/useWallet.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/hooks/useWallet.ts b/app/hooks/useWallet.ts index 661b51e..2aa8470 100644 --- a/app/hooks/useWallet.ts +++ b/app/hooks/useWallet.ts @@ -93,7 +93,6 @@ export const useWallet = () => { ethWallet.isConnected, ethWallet.isConnecting, ethWallet.chainId, - setEthWallet, ]); // Separate effect for authentication that runs when Ethereum address changes @@ -148,7 +147,7 @@ export const useWallet = () => { connected: strkWallet.status === "connected", connecting: strkWallet.status === "connecting", }); - }, [strkWallet.account?.address, strkWallet.status, setStrkWallet]); + }, [strkWallet.account?.address, strkWallet.status]); // Separate effect for Starknet authentication that runs when Starknet address changes useEffect(() => {