Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions app/api/auth/webauthn/authenticate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from "next/server";
import { generateAuthenticationOptions, verifyAuthenticationResponse } from "@simplewebauthn/server";
import { findUserByEmail, updateUser } from "@/lib/auth/users";
import { signToken } from "@/lib/auth/jwt";

const rpID = process.env.NODE_ENV === "production" ? "payeasy.com" : "localhost";
const expectedOrigin = process.env.NODE_ENV === "production"
? "https://payeasy.com"
: "http://localhost:3000";

const COOKIE_OPTS = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
maxAge: 60 * 60 * 24 * 7,
path: "/",
};

// GET: Return authentication options
export async function GET(req: NextRequest) {
const email = req.nextUrl.searchParams.get("email");
if (!email) return NextResponse.json({ error: "Email required" }, { status: 400 });

const user = findUserByEmail(email);
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });

const options = await generateAuthenticationOptions({
rpID,
allowCredentials: user.webAuthnCredentials?.map(cred => ({
id: Buffer.from(cred.id, "base64url"),
type: "public-key",
transports: cred.transports as any[],
})) || [],
userVerification: "preferred",
});

// Save the challenge in the user's record
updateUser(user.id, { currentChallenge: options.challenge });

return NextResponse.json(options);
}

// POST: Verify authentication response
export async function POST(req: NextRequest) {
const body = await req.json();
const { email, response } = body;

if (!email || !response) {
return NextResponse.json({ error: "Missing email or response" }, { status: 400 });
}

const user = findUserByEmail(email);
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });

if (!user.currentChallenge) {
return NextResponse.json({ error: "No authentication challenge found" }, { status: 400 });
}

// Find the exact credential used
const credential = user.webAuthnCredentials?.find(c => c.id === response.id);
if (!credential) {
return NextResponse.json({ error: "Credential not found" }, { status: 400 });
}

try {
const verification = await verifyAuthenticationResponse({
response,
expectedChallenge: user.currentChallenge,
expectedOrigin,
expectedRPID: rpID,
credential: {
id: Buffer.from(credential.id, "base64url"),
publicKey: Buffer.from(credential.publicKeyBase64, "base64"),
counter: credential.counter,
},
});

if (verification.verified) {
// Update the credential counter
const updatedCredentials = user.webAuthnCredentials!.map(c =>
c.id === credential.id ? { ...c, counter: verification.authenticationInfo.newCounter } : c
);

updateUser(user.id, {
webAuthnCredentials: updatedCredentials,
currentChallenge: undefined, // clear challenge
});

// Issue JWT token
const token = await signToken({
userId: user.id,
email: user.email,
name: user.name,
});

const res = NextResponse.json({ verified: true });
res.cookies.set("auth_token", token, COOKIE_OPTS);
return res;
} else {
return NextResponse.json({ error: "Verification failed" }, { status: 400 });
}
} catch (error: any) {
console.error("WebAuthn Authentication Error:", error);
return NextResponse.json({ error: error.message }, { status: 400 });
}
}
96 changes: 96 additions & 0 deletions app/api/auth/webauthn/register/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { NextRequest, NextResponse } from "next/server";
import { generateRegistrationOptions, verifyRegistrationResponse } from "@simplewebauthn/server";
import { verifyToken } from "@/lib/auth/jwt";
import { findUserById, updateUser } from "@/lib/auth/users";

const rpName = "PayEasy";
const rpID = process.env.NODE_ENV === "production" ? "payeasy.com" : "localhost";
const expectedOrigin = process.env.NODE_ENV === "production"
? "https://payeasy.com"
: "http://localhost:3000";

// GET: Return registration options
export async function GET(req: NextRequest) {
const token = req.cookies.get("auth_token")?.value;
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

const payload = await verifyToken(token);
if (!payload) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

const user = findUserById(payload.userId);
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });

const options = await generateRegistrationOptions({
rpName,
rpID,
userID: new TextEncoder().encode(user.id),
userName: user.email,
userDisplayName: user.name,
attestationType: "none",
excludeCredentials: user.webAuthnCredentials?.map(cred => ({
id: Buffer.from(cred.id, "base64url"),
type: "public-key",
transports: cred.transports as any[],
})) || [],
authenticatorSelection: {
residentKey: "preferred",
userVerification: "preferred",
},
});

// Save the challenge in the user's record
updateUser(user.id, { currentChallenge: options.challenge });

return NextResponse.json(options);
}

// POST: Verify registration response
export async function POST(req: NextRequest) {
const token = req.cookies.get("auth_token")?.value;
if (!token) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

const payload = await verifyToken(token);
if (!payload) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

const user = findUserById(payload.userId);
if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });

const body = await req.json();

if (!user.currentChallenge) {
return NextResponse.json({ error: "No registration challenge found" }, { status: 400 });
}

try {
const verification = await verifyRegistrationResponse({
response: body,
expectedChallenge: user.currentChallenge,
expectedOrigin,
expectedRPID: rpID,
});

if (verification.verified && verification.registrationInfo) {
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;

const newCredential = {
id: Buffer.from(credential.id).toString("base64url"),
publicKeyBase64: Buffer.from(credential.publicKey).toString("base64"),
counter: credential.counter,
transports: body.response.transports || [],
};

const existingCredentials = user.webAuthnCredentials || [];
updateUser(user.id, {
webAuthnCredentials: [...existingCredentials, newCredential],
currentChallenge: undefined, // clear challenge
});

return NextResponse.json({ verified: true });
} else {
return NextResponse.json({ error: "Verification failed" }, { status: 400 });
}
} catch (error: any) {
console.error("WebAuthn Registration Error:", error);
return NextResponse.json({ error: error.message }, { status: 400 });
}
}
108 changes: 105 additions & 3 deletions app/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
"use client";

import { useState, FormEvent } from "react";
import { useState, FormEvent, useEffect } from "react";
import { motion } from "framer-motion";
import { Mail, Lock, LogIn, ArrowLeft, Eye, EyeOff, AlertCircle } from "lucide-react";
import { Mail, Lock, LogIn, ArrowLeft, Eye, EyeOff, AlertCircle, Fingerprint } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { PayEasyLogo } from "@/components/ui/payeasy-logo";
import { useEmailAuth } from "@/context/EmailAuthContext";
import { registerWebAuthn, authenticateWebAuthn } from "@/lib/auth/webauthn";
import { browserSupportsWebAuthn } from "@simplewebauthn/browser";

export default function LoginPage() {
const router = useRouter();
Expand All @@ -18,20 +20,106 @@ export default function LoginPage() {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

const [isWebAuthnSupported, setIsWebAuthnSupported] = useState(false);
const [showBiometricsPrompt, setShowBiometricsPrompt] = useState(false);

useEffect(() => {
setIsWebAuthnSupported(browserSupportsWebAuthn());
}, []);

async function handleSubmit(e: FormEvent) {
e.preventDefault();
setError(null);
setIsLoading(true);
try {
await login(email, password);
router.push("/connect");
if (isWebAuthnSupported) {
setShowBiometricsPrompt(true);
} else {
router.push("/connect");
}
} catch (err) {
setError(err instanceof Error ? err.message : "Login failed");
} finally {
setIsLoading(false);
}
}

async function handleBiometricLogin() {
if (!email) {
setError("Please enter your email to login with biometrics.");
return;
}
setError(null);
setIsLoading(true);
try {
await authenticateWebAuthn(email);
window.location.href = "/connect"; // Force reload to update context
} catch (err) {
setError(err instanceof Error ? err.message : "Biometric login failed");
} finally {
setIsLoading(false);
}
}

async function handleRegisterBiometrics() {
setIsLoading(true);
setError(null);
try {
await registerWebAuthn();
window.location.href = "/connect";
} catch (err) {
setError(err instanceof Error ? err.message : "Registration failed");
// Skip on error
setTimeout(() => {
window.location.href = "/connect";
}, 1500);
} finally {
setIsLoading(false);
}
}

if (showBiometricsPrompt) {
return (
<main className="relative min-h-screen flex flex-col items-center justify-center px-4 py-12 overflow-hidden">
<div className="relative z-10 w-full max-w-md">
<motion.div
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center p-8 rounded-2xl glass-card"
>
<Fingerprint className="w-16 h-16 text-brand-400 mb-6" />
<h1 className="text-2xl font-bold text-center text-white mb-2">
Enable Face ID / Fingerprint
</h1>
<p className="text-dark-400 text-center mb-8">
Would you like to log in faster next time using biometrics?
</p>

{error && (
<p className="text-red-400 text-sm mb-4">{error}</p>
)}

<button
onClick={handleRegisterBiometrics}
disabled={isLoading}
className="w-full bg-brand-500 hover:bg-brand-600 text-white rounded-xl py-3.5 mb-3 transition font-semibold"
>
{isLoading ? "Setting up..." : "Enable Biometrics"}
</button>
<button
onClick={() => router.push("/connect")}
disabled={isLoading}
className="w-full bg-dark-800 hover:bg-dark-700 text-dark-300 rounded-xl py-3.5 transition"
>
Not now
</button>
</motion.div>
</div>
</main>
);
}

return (
<main
id="main-content"
Expand Down Expand Up @@ -192,6 +280,20 @@ export default function LoginPage() {
</span>
</div>
</button>

{isWebAuthnSupported && (
<button
type="button"
onClick={handleBiometricLogin}
disabled={isLoading}
className="w-full flex items-center justify-center gap-2 border border-brand-500/30 hover:bg-brand-500/10 text-brand-400 rounded-[15px] px-8 py-4 transition-colors"
>
<Fingerprint className="w-5 h-5" />
<span className="font-semibold text-lg font-display">
Login with Face ID / Fingerprint
</span>
</button>
)}
</form>

{/* Divider */}
Expand Down
3 changes: 2 additions & 1 deletion components/escrow/ContractTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import {
ShieldCheck,
XCircle,
Clock,
ExternalLink
ExternalLink,
Loader2
} from "lucide-react";
import { getExplorerLink } from "@/lib/stellar/explorer";

Expand Down
2 changes: 1 addition & 1 deletion components/history/TransactionCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -192,4 +192,4 @@ export default memo(function TransactionCard({ transaction, isNew = false, onCli
</div>
</div>
);
}
});
3 changes: 2 additions & 1 deletion components/landing/Navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { useState, useEffect } from "react";
import { Menu, X, LogIn, UserPlus, LogOut, User } from "lucide-react";
import { Menu, X, LogIn, UserPlus, LogOut, User, Wallet } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import ConnectWalletButton from "@/components/wallet/ConnectWalletButton";
Expand Down Expand Up @@ -201,6 +201,7 @@ export default function Navbar() {
</Link>
</>
)}
</div>
<div className="flex justify-center gap-3">
<ConnectWalletButton />
<ThemeToggle />
Expand Down
Loading
Loading