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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-18 11:04:55 +00:00
parent 8ed22c9caa
commit a9866f4a32
15 changed files with 198 additions and 297 deletions

View File

@ -12,8 +12,8 @@ services:
- "traefik.docker.network=traefik-public" - "traefik.docker.network=traefik-public"
environment: environment:
- DATABASE_URL=postgresql://rvote:${DB_PASSWORD}@postgres:5432/rvote - DATABASE_URL=postgresql://rvote:${DB_PASSWORD}@postgres:5432/rvote
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET} - ENCRYPTID_SERVER_URL=https://encryptid.jeffemmett.com
- NEXTAUTH_URL=https://rvote.online - NEXT_PUBLIC_ENCRYPTID_SERVER_URL=https://encryptid.jeffemmett.com
- SMTP_HOST=${SMTP_HOST:-mx.jeffemmett.com} - SMTP_HOST=${SMTP_HOST:-mx.jeffemmett.com}
- SMTP_PORT=${SMTP_PORT:-587} - SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER:-noreply@jeffemmett.com} - SMTP_USER=${SMTP_USER:-noreply@jeffemmett.com}

View File

@ -9,16 +9,13 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"@auth/prisma-adapter": "^2.11.1",
"@encryptid/sdk": "file:../encryptid-sdk", "@encryptid/sdk": "file:../encryptid-sdk",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"lucide-react": "^0.563.0", "lucide-react": "^0.563.0",
"next": "16.1.6", "next": "16.1.6",
"next-auth": "^5.0.0-beta.30",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"prisma": "^6.19.2", "prisma": "^6.19.2",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
@ -29,7 +26,6 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",

View File

@ -22,49 +22,10 @@ model User {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
// NextAuth fields
accounts Account[]
sessions Session[]
// Space memberships // Space memberships
spaceMemberships SpaceMember[] 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) ─────────────────────────────────────────── // ─── Spaces (Multi-Tenant) ───────────────────────────────────────────
model Space { model Space {

View File

@ -1,3 +0,0 @@
import { handlers } from "@/lib/auth";
export const { GET, POST } = handlers;

View File

@ -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 }
);
}
}

View File

@ -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;
}

View File

@ -1,22 +1,19 @@
"use client"; "use client";
import { useState, Suspense } from "react"; import { useState, Suspense } from "react";
import { signIn } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Loader2, Fingerprint } from "lucide-react"; import { Loader2, Fingerprint } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { EncryptIDClient } from "@encryptid/sdk/client"; import { useEncryptID } from "@encryptid/sdk/ui/react";
const ENCRYPTID_SERVER = process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL || "https://encryptid.jeffemmett.com";
const encryptid = new EncryptIDClient(ENCRYPTID_SERVER);
function SignInForm() { function SignInForm() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") || "/"; const callbackUrl = searchParams.get("callbackUrl") || "/";
const { login } = useEncryptID();
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false); const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
@ -24,22 +21,22 @@ function SignInForm() {
setIsPasskeyLoading(true); setIsPasskeyLoading(true);
try { try {
// SDK handles the full WebAuthn ceremony // SDK handles the full WebAuthn ceremony + stores token in localStorage
const authResult = await encryptid.authenticate(); await login();
// Exchange EncryptID token for NextAuth session // Get the token from localStorage and set it as a cookie for SSR
const result = await signIn("encryptid", { const token = localStorage.getItem("encryptid_token");
token: authResult.token, if (token) {
redirect: false, await fetch("/api/auth/session", {
}); method: "POST",
headers: { "Content-Type": "application/json" },
if (result?.error) { body: JSON.stringify({ token }),
toast.error("Passkey verified but session creation failed"); });
} else {
toast.success("Signed in with passkey!");
router.push(callbackUrl);
router.refresh();
} }
toast.success("Signed in with passkey!");
router.push(callbackUrl);
router.refresh();
} catch (error: any) { } catch (error: any) {
if (error.name === "NotAllowedError") { if (error.name === "NotAllowedError") {
toast.error("No passkey found. Create an account first."); toast.error("No passkey found. Create an account first.");

View File

@ -1,7 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button"; 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 { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Loader2, Fingerprint } from "lucide-react"; import { Loader2, Fingerprint } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { EncryptIDClient } from "@encryptid/sdk/client"; import { useEncryptID } from "@encryptid/sdk/ui/react";
const ENCRYPTID_SERVER = process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL || "https://encryptid.jeffemmett.com";
const encryptid = new EncryptIDClient(ENCRYPTID_SERVER);
export default function SignUpPage() { export default function SignUpPage() {
const router = useRouter(); const router = useRouter();
const { register } = useEncryptID();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false); const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
@ -27,23 +24,22 @@ export default function SignUpPage() {
try { try {
const username = name.trim() || `user-${Date.now().toString(36)}`; const username = name.trim() || `user-${Date.now().toString(36)}`;
// SDK handles the full WebAuthn registration ceremony // SDK handles the full WebAuthn registration ceremony + stores token
const regResult = await encryptid.register(username, name || username); await register(username, name || username);
// Exchange EncryptID token for NextAuth session // Get the token from localStorage and set it as a cookie for SSR
const result = await signIn("encryptid", { const token = localStorage.getItem("encryptid_token");
token: regResult.token, if (token) {
redirect: false, await fetch("/api/auth/session", {
}); method: "POST",
headers: { "Content-Type": "application/json" },
if (result?.error) { body: JSON.stringify({ token }),
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();
} }
toast.success("Account created with passkey! Welcome to rVote.");
router.push("/");
router.refresh();
} catch (error: any) { } catch (error: any) {
if (error.name === "NotAllowedError") { if (error.name === "NotAllowedError") {
toast.error("Passkey registration was cancelled"); toast.error("Passkey registration was cancelled");

View File

@ -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 (
<Provider serverUrl={process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL}>
{children}
</Provider>
);
}

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import Link from "next/link"; 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 { usePathname } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { CreditDisplay } from "./CreditDisplay"; import { CreditDisplay } from "./CreditDisplay";
@ -15,7 +15,7 @@ import {
import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Avatar, AvatarFallback } from "@/components/ui/avatar";
export function Navbar() { export function Navbar() {
const { data: session, status } = useSession(); const { isAuthenticated, username, did, loading, logout } = useEncryptID();
const pathname = usePathname(); const pathname = usePathname();
// Hide the main navbar on space pages — SpaceNav handles navigation there // Hide the main navbar on space pages — SpaceNav handles navigation there
@ -23,6 +23,14 @@ export function Navbar() {
return null; 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 ( return (
<nav className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <nav className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto px-4"> <div className="container mx-auto px-4">
@ -48,9 +56,9 @@ export function Navbar() {
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{status === "loading" ? ( {loading ? (
<div className="h-8 w-20 animate-pulse bg-muted rounded" /> <div className="h-8 w-20 animate-pulse bg-muted rounded" />
) : session?.user ? ( ) : isAuthenticated ? (
<> <>
<CreditDisplay /> <CreditDisplay />
<DropdownMenu> <DropdownMenu>
@ -61,8 +69,8 @@ export function Navbar() {
> >
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
<AvatarFallback> <AvatarFallback>
{session.user.name?.[0]?.toUpperCase() || {username?.[0]?.toUpperCase() ||
session.user.email?.[0]?.toUpperCase() || did?.[0]?.toUpperCase() ||
"U"} "U"}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
@ -71,12 +79,12 @@ export function Navbar() {
<DropdownMenuContent className="w-56" align="end" forceMount> <DropdownMenuContent className="w-56" align="end" forceMount>
<div className="flex items-center justify-start gap-2 p-2"> <div className="flex items-center justify-start gap-2 p-2">
<div className="flex flex-col space-y-1 leading-none"> <div className="flex flex-col space-y-1 leading-none">
{session.user.name && ( {username && (
<p className="font-medium">{session.user.name}</p> <p className="font-medium">{username}</p>
)} )}
{session.user.email && ( {did && (
<p className="w-[200px] truncate text-sm text-muted-foreground"> <p className="w-[200px] truncate text-sm text-muted-foreground">
{session.user.email} {did}
</p> </p>
)} )}
</div> </div>
@ -91,7 +99,7 @@ export function Navbar() {
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
className="cursor-pointer" className="cursor-pointer"
onClick={() => signOut()} onClick={handleSignOut}
> >
Sign out Sign out
</DropdownMenuItem> </DropdownMenuItem>

View File

@ -1,13 +1,13 @@
"use client"; "use client";
import { SessionProvider } from "next-auth/react"; import { AuthProvider } from "./AuthProvider";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
export function Providers({ children }: { children: React.ReactNode }) { export function Providers({ children }: { children: React.ReactNode }) {
return ( return (
<SessionProvider> <AuthProvider>
{children} {children}
<Toaster position="bottom-right" /> <Toaster position="bottom-right" />
</SessionProvider> </AuthProvider>
); );
} }

View File

@ -1,78 +1,57 @@
import NextAuth from "next-auth"; import { verifyEncryptIDToken } from '@encryptid/sdk/server';
import Credentials from "next-auth/providers/credentials"; import { cookies } from 'next/headers';
import { PrismaAdapter } from "@auth/prisma-adapter"; import { prisma } from './prisma';
import { prisma } from "./prisma";
import { verifyEncryptIDToken } from "./encryptid";
export const { handlers, signIn, signOut, auth } = NextAuth({ const SERVER_URL =
adapter: PrismaAdapter(prisma), process.env.ENCRYPTID_SERVER_URL || 'https://encryptid.jeffemmett.com';
trustHost: true,
session: { interface AuthSession {
strategy: "jwt", user: {
}, id: string;
pages: { email: string;
signIn: "/auth/signin", name: string | null;
error: "/auth/error", did: string | null;
}, };
providers: [ }
// EncryptID passkey login — sole auth provider
Credentials({ /**
id: "encryptid", * Get the current user session.
name: "EncryptID Passkey", * Works in both server components and API route handlers.
credentials: { * Reads the encryptid_token cookie, verifies it, and upserts the user.
token: { label: "Token", type: "text" }, *
* Drop-in replacement for the old NextAuth `auth()` function.
*/
export async function auth(): Promise<AuthSession | null> {
const cookieStore = await cookies();
const token = cookieStore.get('encryptid_token')?.value;
if (!token) return null;
try {
const claims = await verifyEncryptIDToken(token, { serverUrl: SERVER_URL });
const did: string | undefined = claims.did || claims.sub;
if (!did) return null;
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(),
}, },
async authorize(credentials) { });
if (!credentials?.token) {
return null;
}
// Verify the EncryptID JWT return {
const claims = await verifyEncryptIDToken(credentials.token as string); user: {
if (!claims) { id: user.id,
return null; email: user.email,
} name: user.name,
did: user.did,
const did = claims.did || claims.sub;
// Find existing user by DID or create a new one
let user = await prisma.user.findFirst({
where: { did },
});
if (!user) {
// Create new passkey-only user
user = await prisma.user.create({
data: {
email: `${did}@encryptid.local`, // Placeholder email for DID-only users
did,
name: claims.username || null,
credits: 50, // Starting credits
emailVerified: new Date(),
},
});
}
return {
id: user.id,
email: user.email,
name: user.name,
};
}, },
}), };
], } catch {
callbacks: { return null;
async jwt({ token, user }) { }
if (user) { }
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
}
return session;
},
},
});

View File

@ -1,35 +0,0 @@
/**
* EncryptID configuration for rvote-online
*
* Uses @encryptid/sdk for token verification instead of manual HTTP calls.
*/
import { verifyEncryptIDToken as sdkVerify } from '@encryptid/sdk/server';
export const ENCRYPTID_SERVER_URL =
process.env.ENCRYPTID_SERVER_URL || 'https://encryptid.jeffemmett.com';
/**
* Verify an EncryptID JWT token.
* Returns claims if valid, null if invalid.
*/
export async function verifyEncryptIDToken(token: string): Promise<{
sub: string;
username?: string;
did?: string;
exp?: number;
} | null> {
try {
const claims = await sdkVerify(token, {
serverUrl: ENCRYPTID_SERVER_URL,
});
return {
sub: claims.sub,
username: claims.username,
did: claims.did,
exp: claims.exp,
};
} catch {
return null;
}
}

View File

@ -1,12 +1,11 @@
/** /**
* Space Role bridge for rVote * Space Role bridge for rVote
* *
* Bridges NextAuth session + EncryptID SDK SpaceRole system. * Bridges EncryptID auth + SDK SpaceRole system.
* Resolves the user's effective SpaceRole in the current space * Resolves the user's effective SpaceRole in the current space
* by querying the EncryptID membership server. * by querying the EncryptID membership server.
*/ */
import { auth } from './auth';
import { prisma } from './prisma'; import { prisma } from './prisma';
import { import {
SpaceRole, SpaceRole,
@ -14,7 +13,6 @@ import {
type ResolvedRole, type ResolvedRole,
} from '@encryptid/sdk/types'; } from '@encryptid/sdk/types';
import { RVOTE_PERMISSIONS } from '@encryptid/sdk/types/modules'; import { RVOTE_PERMISSIONS } from '@encryptid/sdk/types/modules';
import type { Session } from 'next-auth';
const ENCRYPTID_SERVER = process.env.ENCRYPTID_SERVER_URL || 'https://encryptid.jeffemmett.com'; const ENCRYPTID_SERVER = process.env.ENCRYPTID_SERVER_URL || 'https://encryptid.jeffemmett.com';
@ -105,7 +103,7 @@ export async function resolveUserSpaceRole(
* Check if the current session user has a specific rVote capability. * Check if the current session user has a specific rVote capability.
*/ */
export async function checkVoteCapability( export async function checkVoteCapability(
session: Session | null, session: { user: { id: string } } | null,
spaceSlug: string, spaceSlug: string,
capability: keyof typeof RVOTE_PERMISSIONS.capabilities, capability: keyof typeof RVOTE_PERMISSIONS.capabilities,
): Promise<boolean> { ): Promise<boolean> {

View File

@ -1,10 +0,0 @@
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
did?: string | null;
} & DefaultSession["user"];
}
}