From a9866f4a32aab81dbea9421137b2fe4bcd1d448c Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 18 Feb 2026 11:04:55 +0000 Subject: [PATCH] feat: migrate from NextAuth to pure EncryptID auth Replace the NextAuth Credentials provider wrapper with direct EncryptID SDK integration. The auth() function now reads the encryptid_token cookie directly, keeping the same interface so all API routes and server components work unchanged. - Replace SessionProvider with EncryptIDProvider - Add /api/auth/session endpoint for cookie management - Update signin/signup pages to use SDK login/register + cookie sync - Update Navbar to use useEncryptID() hook - Remove next-auth, @auth/prisma-adapter, bcryptjs dependencies - Drop Account, Session, VerificationToken Prisma models + DB tables - Remove legacy email/password register route Co-Authored-By: Claude Opus 4.6 --- docker-compose.yml | 4 +- package.json | 4 - prisma/schema.prisma | 39 -------- src/app/api/auth/[...nextauth]/route.ts | 3 - src/app/api/auth/register/route.ts | 73 -------------- src/app/api/auth/session/route.ts | 74 ++++++++++++++ src/app/auth/signin/page.tsx | 35 +++---- src/app/auth/signup/page.tsx | 36 +++---- src/components/AuthProvider.tsx | 13 +++ src/components/Navbar.tsx | 30 ++++-- src/components/Providers.tsx | 6 +- src/lib/auth.ts | 127 ++++++++++-------------- src/lib/encryptid.ts | 35 ------- src/lib/space-role.ts | 6 +- src/types/next-auth.d.ts | 10 -- 15 files changed, 198 insertions(+), 297 deletions(-) delete mode 100644 src/app/api/auth/[...nextauth]/route.ts delete mode 100644 src/app/api/auth/register/route.ts create mode 100644 src/app/api/auth/session/route.ts create mode 100644 src/components/AuthProvider.tsx delete mode 100644 src/lib/encryptid.ts delete mode 100644 src/types/next-auth.d.ts diff --git a/docker-compose.yml b/docker-compose.yml index bcf463c..6ada832 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,8 +12,8 @@ services: - "traefik.docker.network=traefik-public" environment: - DATABASE_URL=postgresql://rvote:${DB_PASSWORD}@postgres:5432/rvote - - NEXTAUTH_SECRET=${NEXTAUTH_SECRET} - - NEXTAUTH_URL=https://rvote.online + - ENCRYPTID_SERVER_URL=https://encryptid.jeffemmett.com + - NEXT_PUBLIC_ENCRYPTID_SERVER_URL=https://encryptid.jeffemmett.com - SMTP_HOST=${SMTP_HOST:-mx.jeffemmett.com} - SMTP_PORT=${SMTP_PORT:-587} - SMTP_USER=${SMTP_USER:-noreply@jeffemmett.com} diff --git a/package.json b/package.json index 3914c10..e7464d5 100644 --- a/package.json +++ b/package.json @@ -9,16 +9,13 @@ "lint": "eslint" }, "dependencies": { - "@auth/prisma-adapter": "^2.11.1", "@encryptid/sdk": "file:../encryptid-sdk", "@prisma/client": "^6.19.2", - "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", "lucide-react": "^0.563.0", "next": "16.1.6", - "next-auth": "^5.0.0-beta.30", "next-themes": "^0.4.6", "prisma": "^6.19.2", "radix-ui": "^1.4.3", @@ -29,7 +26,6 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", - "@types/bcryptjs": "^2.4.6", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index eaede54..14c2936 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -22,49 +22,10 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - // NextAuth fields - accounts Account[] - sessions Session[] - // Space memberships spaceMemberships SpaceMember[] } -model Account { - id String @id @default(cuid()) - userId String - type String - provider String - providerAccountId String - refresh_token String? @db.Text - access_token String? @db.Text - expires_at Int? - token_type String? - scope String? - id_token String? @db.Text - session_state String? - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@unique([provider, providerAccountId]) -} - -model Session { - id String @id @default(cuid()) - sessionToken String @unique - userId String - expires DateTime - user User @relation(fields: [userId], references: [id], onDelete: Cascade) -} - -model VerificationToken { - identifier String - token String @unique - expires DateTime - - @@unique([identifier, token]) -} - // ─── Spaces (Multi-Tenant) ─────────────────────────────────────────── model Space { diff --git a/src/app/api/auth/[...nextauth]/route.ts b/src/app/api/auth/[...nextauth]/route.ts deleted file mode 100644 index c55a45e..0000000 --- a/src/app/api/auth/[...nextauth]/route.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { handlers } from "@/lib/auth"; - -export const { GET, POST } = handlers; diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts deleted file mode 100644 index 4ea4e9c..0000000 --- a/src/app/api/auth/register/route.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { prisma } from "@/lib/prisma"; -import bcrypt from "bcryptjs"; -import { NextRequest, NextResponse } from "next/server"; - -export async function POST(req: NextRequest) { - try { - const { email, password, name } = await req.json(); - - if (!email || !password) { - return NextResponse.json( - { error: "Email and password are required" }, - { status: 400 } - ); - } - - // Validate email format - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - if (!emailRegex.test(email)) { - return NextResponse.json( - { error: "Invalid email format" }, - { status: 400 } - ); - } - - // Validate password strength - if (password.length < 8) { - return NextResponse.json( - { error: "Password must be at least 8 characters" }, - { status: 400 } - ); - } - - // Check if user already exists - const existingUser = await prisma.user.findUnique({ - where: { email: email.toLowerCase() }, - }); - - if (existingUser) { - return NextResponse.json( - { error: "An account with this email already exists" }, - { status: 400 } - ); - } - - // Hash password - const passwordHash = await bcrypt.hash(password, 12); - - // Create user with initial credits - const user = await prisma.user.create({ - data: { - email: email.toLowerCase(), - passwordHash, - name: name || null, - credits: 50, // Starting credits - emailVerified: new Date(), // Auto-verify for now - }, - select: { - id: true, - email: true, - name: true, - credits: true, - }, - }); - - return NextResponse.json({ user }, { status: 201 }); - } catch (error) { - console.error("Registration error:", error); - return NextResponse.json( - { error: "Failed to create account" }, - { status: 500 } - ); - } -} diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts new file mode 100644 index 0000000..723e898 --- /dev/null +++ b/src/app/api/auth/session/route.ts @@ -0,0 +1,74 @@ +import { verifyEncryptIDToken } from '@encryptid/sdk/server'; +import { prisma } from '@/lib/prisma'; +import { NextRequest, NextResponse } from 'next/server'; + +const SERVER_URL = + process.env.ENCRYPTID_SERVER_URL || 'https://encryptid.jeffemmett.com'; + +/** + * POST /api/auth/session — Verify EncryptID token and set session cookie. + * Called by signin/signup pages after successful WebAuthn ceremony. + */ +export async function POST(request: NextRequest) { + try { + const { token } = await request.json(); + if (!token || typeof token !== 'string') { + return NextResponse.json({ error: 'Token required' }, { status: 400 }); + } + + const claims = await verifyEncryptIDToken(token, { serverUrl: SERVER_URL }); + const did: string | undefined = claims.did || claims.sub; + if (!did) { + return NextResponse.json({ error: 'Invalid token' }, { status: 401 }); + } + + // Upsert user in DB + const user = await prisma.user.upsert({ + where: { did }, + update: { name: claims.username || undefined }, + create: { + did, + email: `${did}@encryptid.local`, + name: claims.username || null, + credits: 50, + emailVerified: new Date(), + }, + }); + + const response = NextResponse.json({ + user: { + id: user.id, + email: user.email, + name: user.name, + did: user.did, + }, + }); + + response.cookies.set('encryptid_token', token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24 * 7, // 7 days + }); + + return response; + } catch { + return NextResponse.json({ error: 'Invalid token' }, { status: 401 }); + } +} + +/** + * DELETE /api/auth/session — Clear session cookie (sign out). + */ +export async function DELETE() { + const response = NextResponse.json({ ok: true }); + response.cookies.set('encryptid_token', '', { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 0, + }); + return response; +} diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx index 17dab93..3ab1461 100644 --- a/src/app/auth/signin/page.tsx +++ b/src/app/auth/signin/page.tsx @@ -1,22 +1,19 @@ "use client"; import { useState, Suspense } from "react"; -import { signIn } from "next-auth/react"; import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Loader2, Fingerprint } from "lucide-react"; import { toast } from "sonner"; -import { EncryptIDClient } from "@encryptid/sdk/client"; - -const ENCRYPTID_SERVER = process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL || "https://encryptid.jeffemmett.com"; -const encryptid = new EncryptIDClient(ENCRYPTID_SERVER); +import { useEncryptID } from "@encryptid/sdk/ui/react"; function SignInForm() { const router = useRouter(); const searchParams = useSearchParams(); const callbackUrl = searchParams.get("callbackUrl") || "/"; + const { login } = useEncryptID(); const [isPasskeyLoading, setIsPasskeyLoading] = useState(false); @@ -24,22 +21,22 @@ function SignInForm() { setIsPasskeyLoading(true); try { - // SDK handles the full WebAuthn ceremony - const authResult = await encryptid.authenticate(); + // SDK handles the full WebAuthn ceremony + stores token in localStorage + await login(); - // Exchange EncryptID token for NextAuth session - const result = await signIn("encryptid", { - token: authResult.token, - redirect: false, - }); - - if (result?.error) { - toast.error("Passkey verified but session creation failed"); - } else { - toast.success("Signed in with passkey!"); - router.push(callbackUrl); - router.refresh(); + // Get the token from localStorage and set it as a cookie for SSR + const token = localStorage.getItem("encryptid_token"); + if (token) { + await fetch("/api/auth/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); } + + toast.success("Signed in with passkey!"); + router.push(callbackUrl); + router.refresh(); } catch (error: any) { if (error.name === "NotAllowedError") { toast.error("No passkey found. Create an account first."); diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index 15fdbc2..39b471c 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -1,7 +1,6 @@ "use client"; import { useState } from "react"; -import { signIn } from "next-auth/react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { Button } from "@/components/ui/button"; @@ -10,13 +9,11 @@ import { Label } from "@/components/ui/label"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Loader2, Fingerprint } from "lucide-react"; import { toast } from "sonner"; -import { EncryptIDClient } from "@encryptid/sdk/client"; - -const ENCRYPTID_SERVER = process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL || "https://encryptid.jeffemmett.com"; -const encryptid = new EncryptIDClient(ENCRYPTID_SERVER); +import { useEncryptID } from "@encryptid/sdk/ui/react"; export default function SignUpPage() { const router = useRouter(); + const { register } = useEncryptID(); const [name, setName] = useState(""); const [isPasskeyLoading, setIsPasskeyLoading] = useState(false); @@ -27,23 +24,22 @@ export default function SignUpPage() { try { const username = name.trim() || `user-${Date.now().toString(36)}`; - // SDK handles the full WebAuthn registration ceremony - const regResult = await encryptid.register(username, name || username); + // SDK handles the full WebAuthn registration ceremony + stores token + await register(username, name || username); - // Exchange EncryptID token for NextAuth session - const result = await signIn("encryptid", { - token: regResult.token, - redirect: false, - }); - - if (result?.error) { - toast.error("Passkey registered but session creation failed"); - router.push("/auth/signin"); - } else { - toast.success("Account created with passkey! Welcome to rVote."); - router.push("/"); - router.refresh(); + // Get the token from localStorage and set it as a cookie for SSR + const token = localStorage.getItem("encryptid_token"); + if (token) { + await fetch("/api/auth/session", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }); } + + toast.success("Account created with passkey! Welcome to rVote."); + router.push("/"); + router.refresh(); } catch (error: any) { if (error.name === "NotAllowedError") { toast.error("Passkey registration was cancelled"); diff --git a/src/components/AuthProvider.tsx b/src/components/AuthProvider.tsx new file mode 100644 index 0000000..32fd7ae --- /dev/null +++ b/src/components/AuthProvider.tsx @@ -0,0 +1,13 @@ +'use client'; + +import { EncryptIDProvider } from '@encryptid/sdk/ui/react'; + +export function AuthProvider({ children }: { children: React.ReactNode }) { + // Cast to any to bypass React 18/19 type mismatch between SDK and app + const Provider = EncryptIDProvider as any; + return ( + + {children} + + ); +} diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 8012b08..71d507b 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useSession, signOut } from "next-auth/react"; +import { useEncryptID } from "@encryptid/sdk/ui/react"; import { usePathname } from "next/navigation"; import { Button } from "@/components/ui/button"; import { CreditDisplay } from "./CreditDisplay"; @@ -15,7 +15,7 @@ import { import { Avatar, AvatarFallback } from "@/components/ui/avatar"; export function Navbar() { - const { data: session, status } = useSession(); + const { isAuthenticated, username, did, loading, logout } = useEncryptID(); const pathname = usePathname(); // Hide the main navbar on space pages — SpaceNav handles navigation there @@ -23,6 +23,14 @@ export function Navbar() { return null; } + async function handleSignOut() { + // Clear the server-side cookie + await fetch("/api/auth/session", { method: "DELETE" }); + // Clear client-side state + logout(); + window.location.href = "/"; + } + return (