feat: unify auth with EncryptID SDK, remove email/password

Replace inline WebAuthn ceremony with SDK EncryptIDClient.
Remove email/password credentials provider from NextAuth config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-15 09:36:01 -07:00
parent 23971b2f0f
commit 8276dacc24
3 changed files with 21 additions and 353 deletions

View File

@ -5,111 +5,29 @@ import { signIn } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
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);
function SignInForm() {
const router = useRouter();
const searchParams = useSearchParams();
const callbackUrl = searchParams.get("callbackUrl") || "/";
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setIsLoading(true);
try {
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
if (result?.error) {
toast.error("Invalid email or password");
} else {
toast.success("Signed in successfully!");
router.push(callbackUrl);
router.refresh();
}
} catch (error) {
toast.error("An error occurred. Please try again.");
} finally {
setIsLoading(false);
}
}
async function handlePasskeySignIn() {
setIsPasskeyLoading(true);
try {
// Step 1: Get authentication options from EncryptID server
const startRes = await fetch(`${ENCRYPTID_SERVER}/api/auth/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
const { options } = await startRes.json();
// SDK handles the full WebAuthn ceremony
const authResult = await encryptid.authenticate();
// Step 2: Trigger WebAuthn ceremony in the browser
const challenge = Uint8Array.from(atob(options.challenge.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0));
const publicKeyOptions: PublicKeyCredentialRequestOptions = {
challenge: challenge.buffer as ArrayBuffer,
rpId: options.rpId,
userVerification: options.userVerification as UserVerificationRequirement,
timeout: options.timeout,
allowCredentials: options.allowCredentials?.map((c: { type: string; id: string; transports?: string[] }) => ({
type: c.type as PublicKeyCredentialType,
id: Uint8Array.from(atob(c.id.replace(/-/g, "+").replace(/_/g, "/")), ch => ch.charCodeAt(0)).buffer as ArrayBuffer,
transports: c.transports as AuthenticatorTransport[],
})),
};
const assertion = await navigator.credentials.get({ publicKey: publicKeyOptions }) as PublicKeyCredential;
if (!assertion) throw new Error("Passkey authentication cancelled");
const response = assertion.response as AuthenticatorAssertionResponse;
// Helper to convert ArrayBuffer to base64url
function toBase64url(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
// Step 3: Complete authentication with EncryptID server
const completeRes = await fetch(`${ENCRYPTID_SERVER}/api/auth/complete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
challenge: options.challenge,
credential: {
credentialId: assertion.id,
authenticatorData: toBase64url(response.authenticatorData),
clientDataJSON: toBase64url(response.clientDataJSON),
signature: toBase64url(response.signature),
userHandle: response.userHandle ? toBase64url(response.userHandle) : null,
},
}),
});
const authResult = await completeRes.json();
if (!authResult.success) {
throw new Error(authResult.error || "Authentication failed");
}
// Step 4: Exchange EncryptID token for NextAuth session
// Exchange EncryptID token for NextAuth session
const result = await signIn("encryptid", {
token: authResult.token,
redirect: false,
@ -124,7 +42,8 @@ function SignInForm() {
}
} catch (error: any) {
if (error.name === "NotAllowedError") {
toast.error("Passkey authentication was cancelled");
toast.error("No passkey found. Create an account first.");
router.push("/auth/signup");
} else {
toast.error(error.message || "Passkey authentication failed");
}
@ -133,24 +52,20 @@ function SignInForm() {
}
}
const anyLoading = isLoading || isPasskeyLoading;
return (
<Card className="w-full max-w-md">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl font-bold">Sign in</CardTitle>
<CardDescription>
Sign in with a passkey or your email and password
Sign in with your passkey to continue
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Passkey sign-in button */}
<Button
type="button"
variant="outline"
className="w-full gap-2"
onClick={handlePasskeySignIn}
disabled={anyLoading}
disabled={isPasskeyLoading}
>
{isPasskeyLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
@ -159,46 +74,6 @@ function SignInForm() {
)}
Sign in with Passkey
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or continue with email</span>
</div>
</div>
{/* Email/password form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={anyLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={anyLoading}
/>
</div>
<Button type="submit" className="w-full" disabled={anyLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Sign in
</Button>
</form>
</CardContent>
<CardFooter>
<p className="text-sm text-muted-foreground text-center w-full">

View File

@ -10,139 +10,27 @@ 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);
export default function SignUpPage() {
const router = useRouter();
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (password !== confirmPassword) {
toast.error("Passwords do not match");
return;
}
if (password.length < 8) {
toast.error("Password must be at least 8 characters");
return;
}
setIsLoading(true);
try {
// Register the user
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, email, password }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || "Failed to create account");
}
// Sign in automatically
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
if (result?.error) {
toast.error("Account created but failed to sign in. Please try signing in manually.");
router.push("/auth/signin");
} else {
toast.success("Account created! Welcome to rVote.");
router.push("/");
router.refresh();
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "An error occurred");
} finally {
setIsLoading(false);
}
}
async function handlePasskeyRegister() {
setIsPasskeyLoading(true);
try {
const username = name || `user-${Date.now().toString(36)}`;
const username = name.trim() || `user-${Date.now().toString(36)}`;
// Step 1: Get registration options from EncryptID server
const startRes = await fetch(`${ENCRYPTID_SERVER}/api/register/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, displayName: name || username }),
});
const { options, userId } = await startRes.json();
// SDK handles the full WebAuthn registration ceremony
const regResult = await encryptid.register(username, name || username);
// Step 2: Trigger WebAuthn registration ceremony in the browser
function fromBase64url(str: string): Uint8Array {
return Uint8Array.from(atob(str.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0));
}
const publicKeyOptions: PublicKeyCredentialCreationOptions = {
challenge: fromBase64url(options.challenge).buffer as ArrayBuffer,
rp: options.rp,
user: {
id: fromBase64url(options.user.id).buffer as ArrayBuffer,
name: options.user.name,
displayName: options.user.displayName,
},
pubKeyCredParams: options.pubKeyCredParams,
authenticatorSelection: options.authenticatorSelection,
timeout: options.timeout,
attestation: options.attestation as AttestationConveyancePreference,
};
const credential = await navigator.credentials.create({ publicKey: publicKeyOptions }) as PublicKeyCredential;
if (!credential) throw new Error("Passkey registration cancelled");
const response = credential.response as AuthenticatorAttestationResponse;
function toBase64url(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
// Step 3: Complete registration with EncryptID server
const completeRes = await fetch(`${ENCRYPTID_SERVER}/api/register/complete`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
challenge: options.challenge,
userId,
username,
credential: {
credentialId: credential.id,
publicKey: toBase64url(response.getPublicKey?.() || response.attestationObject),
attestationObject: toBase64url(response.attestationObject),
clientDataJSON: toBase64url(response.clientDataJSON),
transports: (response as any).getTransports?.() || [],
},
}),
});
const regResult = await completeRes.json();
if (!regResult.success) {
throw new Error(regResult.error || "Registration failed");
}
// Step 4: Exchange EncryptID token for NextAuth session
// Exchange EncryptID token for NextAuth session
const result = await signIn("encryptid", {
token: regResult.token,
redirect: false,
@ -167,8 +55,6 @@ export default function SignUpPage() {
}
}
const anyLoading = isLoading || isPasskeyLoading;
return (
<div className="flex min-h-[60vh] items-center justify-center">
<Card className="w-full max-w-md">
@ -179,7 +65,6 @@ export default function SignUpPage() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Passkey registration */}
<div className="space-y-2">
<Label htmlFor="passkey-name">Display Name</Label>
<Input
@ -188,15 +73,14 @@ export default function SignUpPage() {
placeholder="Your name"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={anyLoading}
disabled={isPasskeyLoading}
/>
</div>
<Button
type="button"
variant="outline"
className="w-full gap-2"
onClick={handlePasskeyRegister}
disabled={anyLoading}
disabled={isPasskeyLoading}
>
{isPasskeyLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
@ -205,61 +89,9 @@ export default function SignUpPage() {
)}
Sign up with Passkey
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or sign up with email</span>
</div>
</div>
{/* Email/password form */}
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={anyLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="At least 8 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={anyLoading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={anyLoading}
/>
</div>
<p className="text-sm text-muted-foreground">
You&apos;ll start with <strong>50 credits</strong> and earn 10 more each day.
</p>
<Button type="submit" className="w-full" disabled={anyLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create account
</Button>
</form>
<p className="text-sm text-muted-foreground">
You&apos;ll start with <strong>50 credits</strong> and earn 10 more each day.
</p>
</CardContent>
<CardFooter>
<p className="text-sm text-muted-foreground text-center w-full">

View File

@ -2,7 +2,6 @@ import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "./prisma";
import bcrypt from "bcryptjs";
import { verifyEncryptIDToken } from "./encryptid";
export const { handlers, signIn, signOut, auth } = NextAuth({
@ -16,45 +15,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
error: "/auth/error",
},
providers: [
// Email + password login
Credentials({
id: "credentials",
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
const user = await prisma.user.findUnique({
where: { email: credentials.email as string },
});
if (!user || !user.passwordHash) {
return null;
}
const isValid = await bcrypt.compare(
credentials.password as string,
user.passwordHash
);
if (!isValid) {
return null;
}
return {
id: user.id,
email: user.email,
name: user.name,
};
},
}),
// EncryptID passkey login
// EncryptID passkey login — sole auth provider
Credentials({
id: "encryptid",
name: "EncryptID Passkey",