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"
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}

View File

@ -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",

View File

@ -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 {

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";
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.");

View File

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

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";
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 (
<nav className="border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container mx-auto px-4">
@ -48,9 +56,9 @@ export function Navbar() {
</div>
<div className="flex items-center gap-4">
{status === "loading" ? (
{loading ? (
<div className="h-8 w-20 animate-pulse bg-muted rounded" />
) : session?.user ? (
) : isAuthenticated ? (
<>
<CreditDisplay />
<DropdownMenu>
@ -61,8 +69,8 @@ export function Navbar() {
>
<Avatar className="h-8 w-8">
<AvatarFallback>
{session.user.name?.[0]?.toUpperCase() ||
session.user.email?.[0]?.toUpperCase() ||
{username?.[0]?.toUpperCase() ||
did?.[0]?.toUpperCase() ||
"U"}
</AvatarFallback>
</Avatar>
@ -71,12 +79,12 @@ export function Navbar() {
<DropdownMenuContent className="w-56" align="end" forceMount>
<div className="flex items-center justify-start gap-2 p-2">
<div className="flex flex-col space-y-1 leading-none">
{session.user.name && (
<p className="font-medium">{session.user.name}</p>
{username && (
<p className="font-medium">{username}</p>
)}
{session.user.email && (
{did && (
<p className="w-[200px] truncate text-sm text-muted-foreground">
{session.user.email}
{did}
</p>
)}
</div>
@ -91,7 +99,7 @@ export function Navbar() {
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
onClick={() => signOut()}
onClick={handleSignOut}
>
Sign out
</DropdownMenuItem>

View File

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

View File

@ -1,78 +1,57 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "./prisma";
import { verifyEncryptIDToken } from "./encryptid";
import { verifyEncryptIDToken } from '@encryptid/sdk/server';
import { cookies } from 'next/headers';
import { prisma } from './prisma';
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
trustHost: true,
session: {
strategy: "jwt",
},
pages: {
signIn: "/auth/signin",
error: "/auth/error",
},
providers: [
// EncryptID passkey login — sole auth provider
Credentials({
id: "encryptid",
name: "EncryptID Passkey",
credentials: {
token: { label: "Token", type: "text" },
const SERVER_URL =
process.env.ENCRYPTID_SERVER_URL || 'https://encryptid.jeffemmett.com';
interface AuthSession {
user: {
id: string;
email: string;
name: string | null;
did: string | null;
};
}
/**
* Get the current user session.
* Works in both server components and API route handlers.
* Reads the encryptid_token cookie, verifies it, and upserts the user.
*
* 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
const claims = await verifyEncryptIDToken(credentials.token as string);
if (!claims) {
return null;
}
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,
};
return {
user: {
id: user.id,
email: user.email,
name: user.name,
did: user.did,
},
}),
],
callbacks: {
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;
},
},
});
};
} catch {
return null;
}
}

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
*
* Bridges NextAuth session + EncryptID SDK SpaceRole system.
* Bridges EncryptID auth + SDK SpaceRole system.
* Resolves the user's effective SpaceRole in the current space
* by querying the EncryptID membership server.
*/
import { auth } from './auth';
import { prisma } from './prisma';
import {
SpaceRole,
@ -14,7 +13,6 @@ import {
type ResolvedRole,
} from '@encryptid/sdk/types';
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';
@ -105,7 +103,7 @@ export async function resolveUserSpaceRole(
* Check if the current session user has a specific rVote capability.
*/
export async function checkVoteCapability(
session: Session | null,
session: { user: { id: string } } | null,
spaceSlug: string,
capability: keyof typeof RVOTE_PERMISSIONS.capabilities,
): 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"];
}
}