diff --git a/backend/src/auth/auth.module.ts b/backend/src/auth/auth.module.ts index aa9b117..cd5453c 100644 --- a/backend/src/auth/auth.module.ts +++ b/backend/src/auth/auth.module.ts @@ -19,6 +19,7 @@ import { JwtStrategy } from './strategies/jwt.strategy'; import { ForgotPasswordProvider } from './providers/forgot-password.provider'; import { ResetPasswordProvider } from './providers/reset-password.provider'; import { MailService } from './providers/mail.service'; +import { NonceService } from './providers/nonce.service'; @Module({ imports: [ @@ -44,6 +45,7 @@ import { MailService } from './providers/mail.service'; ForgotPasswordProvider, ResetPasswordProvider, MailService, + NonceService, { provide: HashingProvider, // Use the abstract class as a token useClass: BcryptProvider, // Bind it to the concrete implementation @@ -58,6 +60,7 @@ import { MailService } from './providers/mail.service'; AuthService, HashingProvider, GoogleAuthenticationService, + NonceService, ], }) export class AuthModule {} diff --git a/backend/src/auth/authConfig/jwt.config.ts b/backend/src/auth/authConfig/jwt.config.ts index da089b5..27809d4 100644 --- a/backend/src/auth/authConfig/jwt.config.ts +++ b/backend/src/auth/authConfig/jwt.config.ts @@ -3,10 +3,10 @@ import { registerAs } from '@nestjs/config'; export default registerAs('jwt', () => { return { secret: process.env.JWT_SECRET, - audience: process.env.JWT_TOKEN_AUDIENCE, + audience: process.env.JWT_TOKEN_AUDIENCE ?? 'localhost', googleClient_id: process.env.GOOGLE_CLIENT_ID, googleClient_secret: process.env.GOOGLE_CLIENT_SECRET, - issuer: process.env.JWT_TOKEN_ISSUER, + issuer: process.env.JWT_TOKEN_ISSUER ?? 'localhost', ttl: parseInt(process.env.JWT_ACCESS_TOKEN_TTL ?? '3600'), }; }); diff --git a/backend/src/auth/providers/auth.service.ts b/backend/src/auth/providers/auth.service.ts index 2fbea4b..314c436 100644 --- a/backend/src/auth/providers/auth.service.ts +++ b/backend/src/auth/providers/auth.service.ts @@ -11,14 +11,10 @@ import { ForgotPasswordProvider } from './forgot-password.provider'; import { ResetPasswordProvider } from './reset-password.provider'; import { ForgotPasswordDto } from '../dtos/forgot-password.dto'; import { ResetPasswordDto } from '../dtos/reset-password.dto'; +import { NonceService } from './nonce.service'; @Injectable() export class AuthService { - private nonces = new Map< - string, - { walletAddress: string; expiresAt: number; used: boolean } - >(); - constructor( /** * inject signInProvider @@ -44,6 +40,11 @@ export class AuthService { * inject resetPasswordProvider */ private readonly resetPasswordProvider: ResetPasswordProvider, + + /** + * inject nonceService + */ + private readonly nonceService: NonceService, ) {} public async SignIn(signInDto: LoginDto) { @@ -65,22 +66,18 @@ export class AuthService { const nonce = this.createSecureNonce(walletAddress); const expiresAt = Date.now() + 5 * 60 * 1000; // 5 minutes from now - // Store nonce - this.nonces.set(nonce, { - walletAddress, - expiresAt, - used: false, - }); + // Store nonce via NonceService + this.nonceService.storeNonce(nonce, walletAddress, expiresAt); // Clean up expired nonces periodically - this.cleanupExpiredNonces(); + this.nonceService.cleanupExpiredNonces(); return { nonce, expiresAt }; } // Check nonce status (useful for debugging) public checkNonceStatus(nonce: string) { - const nonceData = this.nonces.get(nonce); + const nonceData = this.nonceService.getNonce(nonce); if (!nonceData) { return { valid: false, reason: 'Nonce not found' }; @@ -103,27 +100,7 @@ export class AuthService { // Verify and mark nonce as used (called by StellarWalletLoginProvider) public verifyAndUseNonce(nonce: string, walletAddress: string): void { - const nonceData = this.nonces.get(nonce); - - if (!nonceData) { - throw new BadRequestException('Invalid nonce'); - } - - if (nonceData.used) { - throw new BadRequestException('Nonce already used'); - } - - if (Date.now() > nonceData.expiresAt) { - throw new BadRequestException('Nonce expired'); - } - - if (nonceData.walletAddress !== walletAddress) { - throw new BadRequestException('Nonce wallet address mismatch'); - } - - // Mark as used - nonceData.used = true; - this.nonces.set(nonce, nonceData); + this.nonceService.verifyAndUseNonce(nonce, walletAddress); } private createSecureNonce(walletAddress: string): string { @@ -139,14 +116,6 @@ export class AuthService { return /^[GM][A-Z2-7]{55}$/.test(address); } - private cleanupExpiredNonces(): void { - const now = Date.now(); - for (const [nonce, data] of this.nonces.entries()) { - if (now > data.expiresAt) { - this.nonces.delete(nonce); - } - } - } /** * Handles refreshing of access tokens diff --git a/backend/src/auth/providers/nonce.service.ts b/backend/src/auth/providers/nonce.service.ts new file mode 100644 index 0000000..a177809 --- /dev/null +++ b/backend/src/auth/providers/nonce.service.ts @@ -0,0 +1,70 @@ +import { Injectable, BadRequestException } from '@nestjs/common'; + +@Injectable() +export class NonceService { + private nonces = new Map< + string, + { walletAddress: string; expiresAt: number; used: boolean } + >(); + + public storeNonce(nonce: string, walletAddress: string, expiresAt: number): void { + this.nonces.set(nonce, { + walletAddress, + expiresAt, + used: false, + }); + console.log(`[NonceService] Stored nonce: ${nonce}. Total: ${this.nonces.size}`); + } + + public getNonce(nonce: string) { + return this.nonces.get(nonce); + } + + public markAsUsed(nonce: string): void { + const data = this.nonces.get(nonce); + if (data) { + data.used = true; + this.nonces.set(nonce, data); + } + } + + public verifyAndUseNonce(nonce: string, walletAddress: string): void { + console.log(`[NonceService] Verifying nonce: ${nonce} for wallet ${walletAddress}`); + const nonceData = this.nonces.get(nonce); + + if (!nonceData) { + const known = Array.from(this.nonces.keys()).join(', '); + console.error(`[NonceService] Nonce NOT FOUND: ${nonce}. Map size: ${this.nonces.size}. Known nonces: ${known}`); + throw new BadRequestException('Invalid nonce'); + } + + if (nonceData.used) { + throw new BadRequestException('Nonce already used'); + } + + if (Date.now() > nonceData.expiresAt) { + throw new BadRequestException('Nonce expired'); + } + + if (nonceData.walletAddress !== walletAddress) { + throw new BadRequestException('Nonce wallet address mismatch'); + } + + // Mark as used + this.markAsUsed(nonce); + } + + public cleanupExpiredNonces(): void { + const now = Date.now(); + let count = 0; + for (const [nonce, data] of this.nonces.entries()) { + if (now > data.expiresAt) { + this.nonces.delete(nonce); + count++; + } + } + if (count > 0) { + console.log(`[NonceService] Cleaned up ${count} expired nonces.`); + } + } +} diff --git a/backend/src/auth/providers/wallet-login.provider.ts b/backend/src/auth/providers/wallet-login.provider.ts index 1a7d185..b3ec721 100644 --- a/backend/src/auth/providers/wallet-login.provider.ts +++ b/backend/src/auth/providers/wallet-login.provider.ts @@ -8,7 +8,7 @@ import { JwtService } from '@nestjs/jwt'; import { ConfigType } from '@nestjs/config'; import { UsersService } from '../../users/providers/users.service'; import jwtConfig from '../authConfig/jwt.config'; -import { AuthService } from './auth.service'; +import { NonceService } from './nonce.service'; import * as StellarSdk from 'stellar-sdk'; import * as crypto from 'crypto'; import { StellarWalletLoginDto } from '../dtos/walletLogin.dto'; @@ -24,8 +24,7 @@ export class StellarWalletLoginProvider { // inject jwt service private readonly jwtService: JwtService, - @Inject(forwardRef(() => AuthService)) - private readonly authService: AuthService, + private readonly nonceService: NonceService, // inject jwt @Inject(jwtConfig.KEY) @@ -35,7 +34,7 @@ export class StellarWalletLoginProvider { public async StellarWalletLogin(dto: StellarWalletLoginDto) { try { // 1. Verify nonce hasn't been used and use it (synchronous method) - this.authService.verifyAndUseNonce(dto.nonce, dto.walletAddress); + this.nonceService.verifyAndUseNonce(dto.nonce, dto.walletAddress); // 2. Create proper message to sign const message = this.createLoginMessage(dto.walletAddress, dto.nonce); @@ -59,19 +58,28 @@ export class StellarWalletLoginProvider { } // Check if user exists in db - let user = await this.userService.getOneByWallet(dto.walletAddress); + let user: any = null; + try { + user = await this.userService.getOneByWallet(dto.walletAddress); + } catch (error) { + // If user doesn't exist, getOneByWallet throws UnauthorizedException + // We catch it here so we can proceed to auto-create the user + console.log(`User not found for wallet ${dto.walletAddress}, will attempt auto-creation.`); + } if (!user) { // Auto-create a new user user = await this.userService.create({ walletAddress: dto.walletAddress, publicKey: dto.publicKey, - username: `stellar_user_${dto.walletAddress.slice(0, 6)}`, - provider: 'stellar_wallet', + username: `stellar_user_${dto.walletAddress.slice(-6)}`, + fullname: `Stellar User ${dto.walletAddress.slice(0, 4)}...${dto.walletAddress.slice(-4)}`, + provider: 'wallet', challengeLevel: ChallengeLevel.BEGINNER, challengeTypes: [], ageGroup: AgeGroup.TEENS, }); + console.log(`Successfully created new wallet user: ${user.id}`); } const accessToken = await this.jwtService.signAsync( @@ -90,8 +98,8 @@ export class StellarWalletLoginProvider { } private createLoginMessage(walletAddress: string, nonce: string): string { - // Create a standardized message format for Stellar - return `Login to MyApp\nWallet: ${walletAddress}\nNonce: ${nonce}\nTimestamp: ${Date.now()}`; + // Simply sign the nonce to ensure determinism between frontend and backend + return nonce; } private verifySignature( @@ -99,41 +107,45 @@ export class StellarWalletLoginProvider { signature: string, publicKey: string, ): boolean { + const prefix = 'Stellar Signed Message:\n'; + const fullMessage = prefix + message; + + console.log(`[Signature Verification] + Public Key: ${publicKey} + Raw Message: ${message} + Full Message (SEP-0053): ${JSON.stringify(fullMessage)} + Signature (base64): ${signature}`); + try { - // Convert message to buffer - const messageBuffer = Buffer.from(message, 'utf8'); + // 1. Convert combined message to UTF-8 bytes + const messageBuffer = Buffer.from(fullMessage, 'utf8'); + + // 2. Compute SHA-256 hash of the prefixed message (Standard for SEP-0053) + const messageHash = crypto + .createHash('sha256') + .update(messageBuffer) + .digest(); - // Convert signature from base64 to buffer + // 3. Convert signature from base64 to buffer const signatureBuffer = Buffer.from(signature, 'base64'); - // Convert public key from Stellar format to PEM encoded ed25519 public key - const stellarKeypair = StellarSdk.Keypair.fromPublicKey(publicKey); - const publicKeyBuffer = stellarKeypair.rawPublicKey(); - - // Convert raw public key to PEM format - const publicKeyPem = - '-----BEGIN PUBLIC KEY-----\n' + - Buffer.from(publicKeyBuffer) - .toString('base64') - .match(/.{1,64}/g) - ?.join('\n') + - '\n-----END PUBLIC KEY-----\n'; - - // Verify signature using ed25519 - const isValid = crypto.verify( - null, // algorithm is null for ed25519 - messageBuffer, - { - key: publicKeyPem, - format: 'pem', - type: 'spki', - }, - signatureBuffer, - ); + // 4. Use Stellar SDK's native verification on the HASH + const keypair = StellarSdk.Keypair.fromPublicKey(publicKey); + const isValid = keypair.verify(messageHash, signatureBuffer); + + if (!isValid) { + console.error( + `[Signature Verification Result] FAILED for ${publicKey}`, + ); + } else { + console.log( + `[Signature Verification Result] SUCCESS for ${publicKey}`, + ); + } return isValid; } catch (error) { - console.error('Signature verification error:', error); + console.error('[Signature Verification Error]', error); return false; } } diff --git a/backend/src/users/providers/create-user.service.ts b/backend/src/users/providers/create-user.service.ts index 5027067..439ead4 100644 --- a/backend/src/users/providers/create-user.service.ts +++ b/backend/src/users/providers/create-user.service.ts @@ -22,33 +22,43 @@ export class CreateUserService { async execute(userData: CreateUserDto): Promise { // 1. Validation - if (!userData.email || !isEmail(userData.email)) { - throw new BadRequestException('Invalid email'); - } + const isWalletUser = userData.provider === 'wallet'; + + if (!isWalletUser) { + if (!userData.email || !isEmail(userData.email)) { + throw new BadRequestException('Invalid email'); + } - // 2. Check for existing user - const exists = await this.usersRepository.findOneBy({ - email: userData.email, - }); - if (exists) { - throw new BadRequestException('Email already in use'); + // 2. Check for existing user (only if email is provided) + const exists = await this.usersRepository.findOneBy({ + email: userData.email, + }); + if (exists) { + throw new BadRequestException('Email already in use'); + } } // 3. Create and save user try { - // hash the password before saving - if (!userData.password || typeof userData.password !== 'string') { - throw new BadRequestException( - 'Password is required and must be a string', + // hash the password before saving (only if provided) + if (userData.password) { + if (typeof userData.password !== 'string') { + throw new BadRequestException('Password must be a string'); + } + const hashedPassword = await this.hashingProvider.hashPassword( + userData.password, ); + userData.password = hashedPassword; + } + + // Ensure fullname is never null (required by DB) + if (!userData.fullname) { + userData.fullname = userData.username || 'Anonymous User'; } - const hashedPassword = await this.hashingProvider.hashPassword( - userData.password, - ); - userData.password = hashedPassword; const user = this.usersRepository.create(userData); const savedUser = await this.usersRepository.save(user); + if (Array.isArray(savedUser)) { throw new InternalServerErrorException( 'Unexpected array response while saving user', @@ -56,7 +66,7 @@ export class CreateUserService { } return savedUser; } catch (error) { - throw new InternalServerErrorException(`Failed to create user ${error}`); + throw new InternalServerErrorException(`Failed to create user: ${error}`); } } } diff --git a/frontend/app/auth/signin/page.tsx b/frontend/app/auth/signin/page.tsx index 9624efe..b550a08 100644 --- a/frontend/app/auth/signin/page.tsx +++ b/frontend/app/auth/signin/page.tsx @@ -9,10 +9,19 @@ import { Wallet } from 'lucide-react'; import Image from 'next/image'; import ErrorBoundary from '@/components/ErrorBoundary'; import { useToast } from '@/components/ui/ToastProvider'; +import { useStellarWalletAuth } from '@/hooks/useStellarWalletAuth'; const SignInPage = () => { const router = useRouter(); - const { showSuccess, showError, showInfo } = useToast(); + const { showSuccess, showError, showInfo, showWarning } = useToast(); + const { + isConnecting, + isSigning, + isLoggingIn, + error: walletError, + connectAndLogin, + clearError, + } = useStellarWalletAuth(); const [formData, setFormData] = useState({ username: '', password: '' @@ -60,7 +69,7 @@ const SignInPage = () => { } try { - const response = await fetch('https://mindblock-webaapp.onrender.com/auth/signIn', { + const response = await fetch('http://localhost:3000/auth/signIn', { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -147,7 +156,38 @@ const SignInPage = () => { const handleGoogleSignIn = () => { showInfo('Google Sign-In', 'Redirecting to Google authentication...'); - window.location.href = "https://mindblock-webaapp.onrender.com/auth/google-authentication"; + window.location.href = "http://localhost:3000/auth/google-authentication"; + }; + + const handleWalletLogin = async () => { + clearError(); + + try { + await connectAndLogin('freighter' as any); + + // Success - show toast and redirect + showSuccess('Login Successful', 'Welcome back!'); + router.push('/dashboard'); + } catch (error: any) { + console.error("Wallet Connection Error:", error); + // Error handling with user-friendly messages + if (error?.code === 'WALLET_NOT_INSTALLED') { + showError( + 'Wallet Not Installed', + 'Please install Freighter wallet from freighter.app to continue' + ); + } else if (error?.code === 'USER_REJECTED') { + showWarning('Request Cancelled', 'You cancelled the wallet request'); + } else if (error?.code === 'NONCE_EXPIRED') { + showError('Authentication Expired', 'Please try again'); + } else if (error?.code === 'INVALID_SIGNATURE') { + showError('Authentication Failed', 'Invalid signature or expired nonce'); + } else if (error?.code === 'NETWORK_ERROR') { + showError('Network Error', 'Unable to connect to server. Please try again.'); + } else { + showError('Login Failed', error?.message || 'An unexpected error occurred'); + } + } }; return ( @@ -241,10 +281,15 @@ const SignInPage = () => { diff --git a/frontend/app/auth/signup/page.tsx b/frontend/app/auth/signup/page.tsx index aff22de..9d1bc23 100644 --- a/frontend/app/auth/signup/page.tsx +++ b/frontend/app/auth/signup/page.tsx @@ -9,25 +9,20 @@ import { Wallet } from "lucide-react"; import Image from "next/image"; import ErrorBoundary from "@/components/ErrorBoundary"; import { useToast } from "@/components/ui/ToastProvider"; +import { useStellarWalletAuth } from "@/hooks/useStellarWalletAuth"; -// Proper TypeScript types for Ethereum provider -interface EthereumProvider { - request: (args: { - method: string; - params?: unknown[]; - }) => Promise; - isMetaMask?: boolean; -} - -declare global { - interface Window { - ethereum?: EthereumProvider; - } -} const SignUpPage = () => { const router = useRouter(); const { showSuccess, showError, showWarning, showInfo } = useToast(); + const { + isConnecting, + isSigning, + isLoggingIn, + error: walletError, + connectAndLogin, + clearError, + } = useStellarWalletAuth(); const [formData, setFormData] = useState({ username: "", fullName: "", @@ -133,7 +128,7 @@ const SignUpPage = () => { console.log("Sending request with data:", requestBody); // Debug log const response = await fetch( - "https://mindblock-webaapp.onrender.com/users", + "http://localhost:3000/users", { method: "POST", headers: { @@ -217,6 +212,8 @@ const SignUpPage = () => { if ( data.accessToken || + data.id || + data.email || data.message === "User created successfully" || data.success ) { @@ -267,250 +264,41 @@ const SignUpPage = () => { const handleGoogleSignUp = () => { showInfo("Google Sign-Up", "Redirecting to Google authentication..."); window.location.href = - "https://mindblock-webaapp.onrender.com/auth/google-authentication"; + "http://localhost:3000/auth/google-authentication"; }; const handleWalletConnect = async () => { - try { - showInfo("Wallet Connection", "Connecting to your wallet..."); - - // Check if Web3 is available (MetaMask or similar) - if (typeof window.ethereum !== "undefined") { - // Request account access with type assertion - const accounts = (await window.ethereum.request({ - method: "eth_requestAccounts", - })) as string[]; - - const walletAddress = accounts[0]; - - if (walletAddress) { - // Generate a nonce for authentication - const timestamp = Date.now(); - const randomString = Math.random().toString(36).substring(2, 15); - const nonce = `nonce_${timestamp}_${randomString}_${Math.random().toString(36).substring(2, 8)}`; - - // Create a message for signing that includes the nonce - const message = `Sign this message to authenticate with Mind Block. Nonce: ${nonce}`; - - try { - // Request signature using personal_sign with type assertion - const signature = (await window.ethereum.request({ - method: "personal_sign", - params: [message, walletAddress], - })) as string; - - // Get the public key (this might not be directly available from MetaMask) - // For now, we'll use a placeholder or try to derive it - let publicKey = ""; - try { - // Try to get public key - this might not work with all wallets - const encryptionKey = (await window.ethereum.request({ - method: "eth_getEncryptionPublicKey", - params: [walletAddress], - })) as string; - - // Convert base64 to hex format if needed - if (encryptionKey && !encryptionKey.startsWith("0x")) { - try { - // Convert base64 to hex using browser's atob - const binaryString = atob(encryptionKey); - const bytes = new Uint8Array(binaryString.length); - for (let i = 0; i < binaryString.length; i++) { - bytes[i] = binaryString.charCodeAt(i); - } - publicKey = - "0x" + - Array.from(bytes) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - } catch (convError) { - console.warn( - "Could not convert public key format:", - convError, - ); - publicKey = encryptionKey; // Use as-is if conversion fails - } - } else { - publicKey = encryptionKey || ""; - } - } catch (pkError) { - console.warn("Could not get public key:", pkError); - // Generate a placeholder public key in correct hex format - publicKey = - "0x" + - Array(128) - .fill(0) - .map(() => Math.floor(Math.random() * 16).toString(16)) - .join(""); - } - - // Format the request body to match the expected wallet login format - const requestBody = { - walletAddress: walletAddress, - signature: [signature], // Single signature in array format - nonce: nonce, - publicKey: publicKey, - }; - - console.log("Wallet login request:", requestBody); // Debug log - - // Use the wallet login endpoint - const response = await fetch( - "https://mindblock-webaapp.onrender.com/auth/wallet-login", - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(requestBody), - }, - ); + clearError(); - console.log("Wallet login response status:", response.status); - - if (response.ok) { - const data = await response.json(); - console.log("Wallet login response data:", data); - - // Store token if provided - if (data.accessToken || data.token) { - try { - localStorage.setItem( - "accessToken", - data.accessToken || data.token, - ); - } catch (storageError) { - console.warn( - "Could not save token to localStorage:", - storageError, - ); - } - } - - showSuccess( - "Wallet Connected", - "Successfully authenticated with wallet!", - ); - - // Redirect based on response - setTimeout(() => { - if (data.accessToken || data.token) { - router.push("/dashboard"); - } else { - router.push("/auth/signin"); - } - }, 1000); - } else { - // Handle wallet login errors - try { - const errorData = await response.json(); - console.log("Wallet login error data:", errorData); - - let errorMessage = "Wallet authentication failed"; - - // Handle nested message object - if (errorData.message) { - if (typeof errorData.message === "string") { - errorMessage = errorData.message; - } else if (typeof errorData.message === "object") { - // If message is an object, try to extract meaningful info - if (errorData.message.error) { - errorMessage = errorData.message.error; - } else if (errorData.message.details) { - errorMessage = errorData.message.details; - } else { - // Convert object to string for debugging - errorMessage = JSON.stringify(errorData.message); - } - } - } else if (typeof errorData.error === "string") { - errorMessage = errorData.error; - } - - if (response.status === 404) { - // Wallet not registered, suggest registration - showError( - "Wallet Not Registered", - "This wallet is not registered. Please sign up first.", - ); - } else if (response.status === 401) { - showError( - "Authentication Failed", - "Invalid signature. Please try again.", - ); - } else if (response.status === 400) { - showError("Invalid Request", `Bad request: ${errorMessage}`); - } else { - showError("Wallet Login Failed", errorMessage); - } - } catch (parseError) { - console.error( - "Error parsing wallet login response:", - parseError, - ); - showError( - "Wallet Login Failed", - `Error ${response.status}: Please try again.`, - ); - } - } - } catch (signError: unknown) { - console.error("Signature error:", signError); - - // Type guard to check if it's an error with a code property - const isWalletError = ( - error: unknown, - ): error is { code: number; message?: string } => { - return ( - typeof error === "object" && - error !== null && - "code" in error && - typeof (error as { code: unknown }).code === "number" - ); - }; - - if (isWalletError(signError) && signError.code === 4001) { - showWarning( - "Signature Cancelled", - "Message signing was cancelled by user", - ); - } else { - showError( - "Signature Error", - "Failed to sign message. Please try again.", - ); - } - } - } - } else { - // No Web3 wallet detected - showWarning( - "Wallet Not Found", - "Please install MetaMask or another Web3 wallet to continue", - ); - } - } catch (error: unknown) { - console.error("Wallet connection error:", error); - - // Type guard to check if it's an error with a code property - const isWalletError = (err: unknown): err is { code: number } => { - return ( - typeof err === "object" && - err !== null && - "code" in err && - typeof (err as { code: unknown }).code === "number" + try { + await connectAndLogin("freighter" as any); + + // Success - show toast and redirect + showSuccess("Login Successful", "Welcome back!"); + router.push("/dashboard"); + } catch (error: any) { + console.error("Wallet Connection Error:", error); + // Error handling with user-friendly messages + if (error?.code === "WALLET_NOT_INSTALLED") { + showError( + "Wallet Not Installed", + "Please install Freighter wallet from freighter.app to continue", ); - }; - - if (isWalletError(error) && error.code === 4001) { - showWarning( - "Connection Cancelled", - "Wallet connection was cancelled by user", + } else if (error?.code === "USER_REJECTED") { + showWarning("Request Cancelled", "You cancelled the wallet request"); + } else if (error?.code === "NONCE_EXPIRED") { + showError("Authentication Expired", "Please try again"); + } else if (error?.code === "INVALID_SIGNATURE") { + showError("Authentication Failed", "Invalid signature or expired nonce"); + } else if (error?.code === "NETWORK_ERROR") { + showError( + "Network Error", + "Unable to connect to server. Please try again.", ); } else { showError( - "Wallet Error", - "Failed to connect wallet. Please try again.", + "Login Failed", + error?.message || "An unexpected error occurred", ); } } @@ -628,11 +416,17 @@ const SignUpPage = () => { diff --git a/frontend/hooks/useStellarWalletAuth.ts b/frontend/hooks/useStellarWalletAuth.ts new file mode 100644 index 0000000..76610c6 --- /dev/null +++ b/frontend/hooks/useStellarWalletAuth.ts @@ -0,0 +1,161 @@ + +'use client'; + +import { useState, useCallback } from 'react'; +import type { WalletType, WalletAuthState } from '../lib/stellar/types'; +import { StellarAuthError } from '../lib/stellar/types'; +import { connectWallet, signMessageWithWallet } from '../lib/stellar/wallets'; +import { fetchNonce, submitWalletLogin } from '../lib/stellar/api'; + +const AUTH_MESSAGE = 'Sign this message to authenticate with Mindblock.'; + +export function useStellarWalletAuth() { + const [state, setState] = useState({ + isConnecting: false, + isSigning: false, + isLoggingIn: false, + error: null, + walletAddress: null, + isAuthenticated: typeof window !== 'undefined' ? !!localStorage.getItem('accessToken') : false, + }); + + /** + * Clear error state + */ + const clearError = useCallback(() => { + setState((prev) => ({ ...prev, error: null })); + }, []); + + /** + * Set error state with user-friendly message + */ + const setError = useCallback((error: unknown) => { + let errorMessage = 'An unexpected error occurred'; + + if (error instanceof StellarAuthError) { + switch (error.code) { + case 'WALLET_NOT_INSTALLED': + errorMessage = 'Wallet not installed. Please install Freighter from freighter.app'; + break; + case 'USER_REJECTED': + errorMessage = 'Request was cancelled'; + break; + case 'NONCE_FETCH_FAILED': + errorMessage = 'Failed to initialize authentication. Please try again.'; + break; + case 'RATE_LIMIT': + errorMessage = 'Too many requests. Please wait a moment and try again.'; + break; + case 'INVALID_SIGNATURE': + errorMessage = 'Authentication failed. The signature is invalid or has expired.'; + break; + case 'NETWORK_ERROR': + errorMessage = 'Network error. Please check your connection and try again.'; + break; + case 'SIGNING_FAILED': + errorMessage = 'Failed to sign message. Please try again.'; + break; + default: + errorMessage = error.message || errorMessage; + } + } else if (error instanceof Error) { + errorMessage = error.message; + } + + setState((prev) => ({ ...prev, error: errorMessage })); + }, []); + + /** + * Complete wallet authentication flow + */ + const connectAndLogin = useCallback( + async (walletType: WalletType = 'freighter' as WalletType) => { + clearError(); + + try { + // Step 1: Connect wallet + setState((prev) => ({ ...prev, isConnecting: true, error: null })); + + const walletAddress = await connectWallet(walletType); + + setState((prev) => ({ + ...prev, + isConnecting: false, + walletAddress, + })); + + // Step 2: Fetch nonce from backend + const nonceResponse = await fetchNonce(walletAddress); + + // Check if nonce is expired + if (nonceResponse.expiresAt < Date.now()) { + throw new StellarAuthError('Nonce has expired. Please try again.', 'NONCE_EXPIRED'); + } + + // Step 3: Sign the nonce with wallet + setState((prev) => ({ ...prev, isSigning: true })); + + const signature = await signMessageWithWallet(nonceResponse.nonce, walletType); + + setState((prev) => ({ ...prev, isSigning: false })); + + // Step 4: Submit signature to backend + setState((prev) => ({ ...prev, isLoggingIn: true })); + + const loginResponse = await submitWalletLogin({ + walletAddress, + publicKey: walletAddress, // For account-based wallets, these are the same + signature, + nonce: nonceResponse.nonce, + }); + + // Step 5: Store JWT token + localStorage.setItem('accessToken', loginResponse.accessToken); + + setState((prev) => ({ + ...prev, + isLoggingIn: false, + isAuthenticated: true, + error: null, + })); + + return loginResponse.accessToken; + } catch (error) { + setError(error); + + setState((prev) => ({ + ...prev, + isConnecting: false, + isSigning: false, + isLoggingIn: false, + isAuthenticated: false, + })); + + throw error; + } + }, + [clearError, setError] + ); + + /** + * Logout and clear authentication state + */ + const logout = useCallback(() => { + localStorage.removeItem('accessToken'); + setState({ + isConnecting: false, + isSigning: false, + isLoggingIn: false, + error: null, + walletAddress: null, + isAuthenticated: false, + }); + }, []); + + return { + ...state, + connectAndLogin, + logout, + clearError, + }; +} diff --git a/frontend/lib/stellar/api.ts b/frontend/lib/stellar/api.ts new file mode 100644 index 0000000..fcc2c58 --- /dev/null +++ b/frontend/lib/stellar/api.ts @@ -0,0 +1,138 @@ +/** + * API Client for Stellar Wallet Authentication + */ + +import type { NonceResponse, LoginRequest, LoginResponse } from './types'; +import { StellarAuthError } from './types'; + +const API_BASE_URL = 'http://localhost:3000'; + +/** + * Fetch nonce from backend for wallet authentication + */ +export async function fetchNonce(walletAddress: string): Promise { + if (!walletAddress) { + throw new StellarAuthError('Wallet address is required', 'INVALID_ADDRESS'); + } + + try { + const response = await fetch( + `${API_BASE_URL}/auth/stellar-wallet-nonce?walletAddress=${encodeURIComponent(walletAddress)}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + if (!response.ok) { + if (response.status === 429) { + throw new StellarAuthError( + 'Too many requests. Please try again in a moment.', + 'RATE_LIMIT' + ); + } + + let errorMessage = 'Failed to fetch nonce'; + try { + const errorData = await response.json(); + errorMessage = errorData.message || errorMessage; + } catch { + // Unable to parse error response + } + + throw new StellarAuthError(errorMessage, 'NONCE_FETCH_FAILED'); + } + + const data = await response.json(); + + if (!data.nonce || !data.expiresAt) { + throw new StellarAuthError('Invalid nonce response from server', 'INVALID_RESPONSE'); + } + + return data; + } catch (error) { + if (error instanceof StellarAuthError) { + throw error; + } + + throw new StellarAuthError( + 'Network error. Please check your connection.', + 'NETWORK_ERROR', + error + ); + } +} + +/** + * Submit wallet login request to backend + */ +export async function submitWalletLogin(request: LoginRequest): Promise { + if (!request.walletAddress || !request.signature || !request.nonce || !request.publicKey) { + throw new StellarAuthError('Missing required login parameters', 'INVALID_REQUEST'); + } + + console.log('Submitting wallet login signature to backend...'); + console.log('Login request payload:', request); + try { + const response = await fetch(`${API_BASE_URL}/auth/stellar-wallet-login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + let errorMessage = 'Login failed'; + + try { + const errorData = await response.json(); + console.log('Login Error Data:', errorData); + if (Array.isArray(errorData.message)) { + errorMessage = errorData.message.join(', '); + } else if (typeof errorData.message === 'object' && errorData.message !== null) { + errorMessage = JSON.stringify(errorData.message); + } else { + errorMessage = errorData.message || errorMessage; + } + + if (response.status === 401) { + throw new StellarAuthError( + errorMessage || 'Invalid signature or expired nonce', + 'INVALID_SIGNATURE' + ); + } + + if (response.status === 400) { + throw new StellarAuthError(errorMessage || 'Invalid request', 'INVALID_REQUEST'); + } + } catch (error) { + if (error instanceof StellarAuthError) { + throw error; + } + } + + throw new StellarAuthError(errorMessage, 'LOGIN_FAILED'); + } + + const data = await response.json(); + + if (!data.accessToken) { + throw new StellarAuthError('Invalid response from server', 'INVALID_RESPONSE'); + } + + return data; + } catch (error) { + if (error instanceof StellarAuthError) { + throw error; + } + + throw new StellarAuthError( + 'Network error. Please check your connection.', + 'NETWORK_ERROR', + error + ); + } +} diff --git a/frontend/lib/stellar/types.ts b/frontend/lib/stellar/types.ts new file mode 100644 index 0000000..84af957 --- /dev/null +++ b/frontend/lib/stellar/types.ts @@ -0,0 +1,52 @@ +/** + * Stellar Wallet Authentication Types + */ + +export enum WalletType { + Freighter = 'freighter', + xBull = 'xbull', + Albedo = 'albedo', +} + +export interface NonceResponse { + nonce: string; + expiresAt: number; +} + +export interface LoginRequest { + walletAddress: string; + publicKey: string; + signature: string; + nonce: string; +} + +export interface LoginResponse { + accessToken: string; +} + +export interface WalletConnectionState { + isConnected: boolean; + walletAddress: string | null; + walletType: WalletType | null; + error: string | null; +} + +export class StellarAuthError extends Error { + constructor( + message: string, + public code?: string, + public details?: unknown + ) { + super(message); + this.name = 'StellarAuthError'; + } +} + +export interface WalletAuthState { + isConnecting: boolean; + isSigning: boolean; + isLoggingIn: boolean; + error: string | null; + walletAddress: string | null; + isAuthenticated: boolean; +} diff --git a/frontend/lib/stellar/wallets.ts b/frontend/lib/stellar/wallets.ts new file mode 100644 index 0000000..150c0be --- /dev/null +++ b/frontend/lib/stellar/wallets.ts @@ -0,0 +1,203 @@ + + +import freighterApi from '@stellar/freighter-api'; +import type { WalletType } from './types'; +import { StellarAuthError } from './types'; + + +export async function detectWallet(type: WalletType): Promise { + try { + switch (type) { + case 'freighter': { + const result = await freighterApi.isConnected(); + return result.isConnected; + } + case 'xbull': + // xBull detection - check for window.xBullSDK + return typeof window !== 'undefined' && 'xBullSDK' in window; + case 'albedo': + // Albedo detection - check for window.albedo + return typeof window !== 'undefined' && 'albedo' in window; + default: + return false; + } + } catch { + return false; + } +} + +/** + * Get list of all available (installed) wallets + */ +export async function getAvailableWallets(): Promise { + const wallets: WalletType[] = []; + + if (await detectWallet('freighter' as WalletType)) { + wallets.push('freighter' as WalletType); + } + + if (await detectWallet('xbull' as WalletType)) { + wallets.push('xbull' as WalletType); + } + + if (await detectWallet('albedo' as WalletType)) { + wallets.push('albedo' as WalletType); + } + + return wallets; +} + +/** + * Connect to wallet and retrieve public key + */ +export async function connectWallet(type: WalletType): Promise { + console.log(`Attempting to connect to wallet: ${type}`); + try { + switch (type) { + case 'freighter': { + const connectionResult = await freighterApi.isConnected(); + if (!connectionResult.isConnected) { + throw new StellarAuthError( + 'Freighter wallet is not installed. Please install it from freighter.app', + 'WALLET_NOT_INSTALLED' + ); + } + + const addressResponse = await freighterApi.requestAccess(); + + if (addressResponse.error) { + throw new StellarAuthError( + `Freighter error: ${addressResponse.error}`, + 'CONNECTION_FAILED' + ); + } + + const publicKey = addressResponse.address; + + if (!publicKey) { + throw new StellarAuthError( + 'Failed to get wallet address. Please try again.', + 'CONNECTION_FAILED' + ); + } + + return publicKey; + } + + case 'xbull': + throw new StellarAuthError( + 'xBull wallet support is not yet implemented', + 'WALLET_NOT_SUPPORTED' + ); + + case 'albedo': + throw new StellarAuthError( + 'Albedo wallet support is not yet implemented', + 'WALLET_NOT_SUPPORTED' + ); + + default: + throw new StellarAuthError('Unsupported wallet type', 'WALLET_NOT_SUPPORTED'); + } + } catch (error) { + if (error instanceof StellarAuthError) { + throw error; + } + + // Handle user rejection + if (error instanceof Error && (error.message.includes('User declined') || error.message.includes('dismissed'))) { + throw new StellarAuthError('Wallet connection was cancelled', 'USER_REJECTED'); + } + + console.error(`Connection failed for ${type}:`, error); + throw new StellarAuthError( + 'Failed to connect to wallet', + 'CONNECTION_FAILED', + error + ); + } +} + +/** + * Sign a message with the connected wallet + */ +export async function signMessageWithWallet( + message: string, + walletType: WalletType +): Promise { + console.log(`Requesting signature from ${walletType} for message:`, message); + if (!message) { + throw new StellarAuthError('Message to sign is required', 'INVALID_MESSAGE'); + } + + try { + switch (walletType) { + case 'freighter': { + const signatureResponse = await freighterApi.signMessage(message); + + if ('error' in signatureResponse && signatureResponse.error) { + throw new StellarAuthError( + `Freighter signing error: ${signatureResponse.error}`, + 'SIGNING_FAILED' + ); + } + + // Extract signature from response + const signed = signatureResponse.signedMessage; + let signature: string | null = null; + + if (typeof signed === 'string') { + signature = signed; + } else if (signed instanceof Uint8Array || (signed && typeof (signed as any).length === 'number')) { + // Robust conversion for Uint8Array to base64 in browser + const binary = Array.from(new Uint8Array(signed as any)) + .map(b => String.fromCharCode(b)) + .join(''); + signature = window.btoa(binary); + } + + console.log(`Generated signature (base64): ${signature}`); + + if (!signature) { + throw new StellarAuthError( + 'Failed to sign message or invalid signature response. Please try again.', + 'SIGNING_FAILED' + ); + } + + return signature; + } + + case 'xbull': + throw new StellarAuthError( + 'xBull wallet signing is not yet implemented', + 'WALLET_NOT_SUPPORTED' + ); + + case 'albedo': + throw new StellarAuthError( + 'Albedo wallet signing is not yet implemented', + 'WALLET_NOT_SUPPORTED' + ); + + default: + throw new StellarAuthError('Unsupported wallet type', 'WALLET_NOT_SUPPORTED'); + } + } catch (error) { + if (error instanceof StellarAuthError) { + throw error; + } + + // Handle user rejection + if (error instanceof Error && (error.message.includes('User declined') || error.message.includes('dismissed'))) { + throw new StellarAuthError('Message signing was cancelled', 'USER_REJECTED'); + } + + console.error(`Signing failed for ${walletType}:`, error); + throw new StellarAuthError( + 'Failed to sign message', + 'SIGNING_FAILED', + error + ); + } +} diff --git a/frontend/package.json b/frontend/package.json index 903da67..56d9549 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@reduxjs/toolkit": "^2.11.2", + "@stellar/freighter-api": "^6.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.542.0", @@ -20,6 +21,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-redux": "^9.2.0", + "stellar-sdk": "^13.3.0", "tailwind-merge": "^3.4.0" }, "devDependencies": { diff --git a/package-lock.json b/package-lock.json index 6973767..a1597fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -691,6 +691,7 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "@reduxjs/toolkit": "^2.11.2", + "@stellar/freighter-api": "^6.0.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.542.0", @@ -698,6 +699,7 @@ "react": "19.1.0", "react-dom": "19.1.0", "react-redux": "^9.2.0", + "stellar-sdk": "^13.3.0" "tailwind-merge": "^3.4.0" }, "devDependencies": { @@ -4550,6 +4552,28 @@ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", "license": "MIT" }, + "node_modules/@stellar/freighter-api": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@stellar/freighter-api/-/freighter-api-6.0.1.tgz", + "integrity": "sha512-eqwakEqSg+zoLuPpSbKyrX0pG8DQFzL/J5GtbfuMCmJI+h+oiC9pQ5C6QLc80xopZQKdGt8dUAFCmDMNdAG95w==", + "license": "Apache-2.0", + "dependencies": { + "buffer": "6.0.3", + "semver": "7.7.1" + } + }, + "node_modules/@stellar/freighter-api/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@stellar/js-xdr": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz",