feat: add EncryptID passkey authentication

Add passkey-based sign-in alongside existing email/password auth:
- Add encryptid credentials provider to NextAuth config
- Add DID field to User model (Prisma schema)
- Add passkey sign-in button to signin page (WebAuthn ceremony)
- Add passkey registration to signup page
- Server-side token verification via EncryptID server
- Auto-creates user from DID on first passkey login

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-13 07:34:21 -07:00
parent 90865039f5
commit f48a98d520
5 changed files with 372 additions and 44 deletions

View File

@ -12,6 +12,7 @@ model User {
email String @unique
passwordHash String?
name String?
did String? @unique // EncryptID DID (passkey identity)
credits Int @default(0)
lastCreditAt DateTime @default(now())
emailVerified DateTime?

View File

@ -8,9 +8,11 @@ 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 } from "lucide-react";
import { Loader2, Fingerprint } from "lucide-react";
import { toast } from "sonner";
const ENCRYPTID_SERVER = process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL || "https://encryptid.jeffemmett.com";
function SignInForm() {
const router = useRouter();
const searchParams = useSearchParams();
@ -19,6 +21,7 @@ function SignInForm() {
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();
@ -45,16 +48,129 @@ function SignInForm() {
}
}
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();
// 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,
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)),
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
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();
}
} catch (error: any) {
if (error.name === "NotAllowedError") {
toast.error("Passkey authentication was cancelled");
} else {
toast.error(error.message || "Passkey authentication failed");
}
} finally {
setIsPasskeyLoading(false);
}
}
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>
Enter your email and password to access your account
Sign in with a passkey or your email and password
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<CardContent className="space-y-4">
{/* Passkey sign-in button */}
<Button
type="button"
variant="outline"
className="w-full gap-2"
onClick={handlePasskeySignIn}
disabled={anyLoading}
>
{isPasskeyLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Fingerprint className="h-4 w-4" />
)}
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
@ -64,7 +180,7 @@ function SignInForm() {
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
disabled={anyLoading}
/>
</div>
<div className="space-y-2">
@ -75,23 +191,23 @@ function SignInForm() {
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
disabled={anyLoading}
/>
</div>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
<Button type="submit" className="w-full" disabled={isLoading}>
<Button type="submit" className="w-full" disabled={anyLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Sign in
</Button>
<p className="text-sm text-muted-foreground text-center">
Don&apos;t have an account?{" "}
<Link href="/auth/signup" className="text-primary hover:underline">
Sign up
</Link>
</p>
</CardFooter>
</form>
</form>
</CardContent>
<CardFooter>
<p className="text-sm text-muted-foreground text-center w-full">
Don&apos;t have an account?{" "}
<Link href="/auth/signup" className="text-primary hover:underline">
Sign up
</Link>
</p>
</CardFooter>
</Card>
);
}

View File

@ -8,9 +8,11 @@ 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 } from "lucide-react";
import { Loader2, Fingerprint } from "lucide-react";
import { toast } from "sonner";
const ENCRYPTID_SERVER = process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL || "https://encryptid.jeffemmett.com";
export default function SignUpPage() {
const router = useRouter();
@ -19,6 +21,7 @@ export default function SignUpPage() {
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();
@ -71,6 +74,101 @@ export default function SignUpPage() {
}
}
async function handlePasskeyRegister() {
setIsPasskeyLoading(true);
try {
const username = name || `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();
// 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),
rp: options.rp,
user: {
id: fromBase64url(options.user.id),
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
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();
}
} catch (error: any) {
if (error.name === "NotAllowedError") {
toast.error("Passkey registration was cancelled");
} else {
toast.error(error.message || "Passkey registration failed");
}
} finally {
setIsPasskeyLoading(false);
}
}
const anyLoading = isLoading || isPasskeyLoading;
return (
<div className="flex min-h-[60vh] items-center justify-center">
<Card className="w-full max-w-md">
@ -80,19 +178,45 @@ export default function SignUpPage() {
Join rVote to start ranking and voting on proposals
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Name (optional)</Label>
<Input
id="name"
type="text"
placeholder="Your name"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={isLoading}
/>
<CardContent className="space-y-4">
{/* Passkey registration */}
<div className="space-y-2">
<Label htmlFor="passkey-name">Display Name</Label>
<Input
id="passkey-name"
type="text"
placeholder="Your name"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={anyLoading}
/>
</div>
<Button
type="button"
variant="outline"
className="w-full gap-2"
onClick={handlePasskeyRegister}
disabled={anyLoading}
>
{isPasskeyLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Fingerprint className="h-4 w-4" />
)}
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
@ -102,7 +226,7 @@ export default function SignUpPage() {
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
disabled={anyLoading}
/>
</div>
<div className="space-y-2">
@ -114,7 +238,7 @@ export default function SignUpPage() {
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
disabled={anyLoading}
/>
</div>
<div className="space-y-2">
@ -125,26 +249,26 @@ export default function SignUpPage() {
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={isLoading}
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>
</CardContent>
<CardFooter className="flex flex-col space-y-4">
<Button type="submit" className="w-full" disabled={isLoading}>
<Button type="submit" className="w-full" disabled={anyLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create account
</Button>
<p className="text-sm text-muted-foreground text-center">
Already have an account?{" "}
<Link href="/auth/signin" className="text-primary hover:underline">
Sign in
</Link>
</p>
</CardFooter>
</form>
</form>
</CardContent>
<CardFooter>
<p className="text-sm text-muted-foreground text-center w-full">
Already have an account?{" "}
<Link href="/auth/signin" className="text-primary hover:underline">
Sign in
</Link>
</p>
</CardFooter>
</Card>
</div>
);

View File

@ -3,6 +3,7 @@ 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({
adapter: PrismaAdapter(prisma),
@ -15,7 +16,9 @@ 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" },
@ -50,6 +53,52 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
};
},
}),
// EncryptID passkey login
Credentials({
id: "encryptid",
name: "EncryptID Passkey",
credentials: {
token: { label: "Token", type: "text" },
},
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,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {

38
src/lib/encryptid.ts Normal file
View File

@ -0,0 +1,38 @@
/**
* EncryptID configuration for rvote-online
*/
export const ENCRYPTID_SERVER_URL =
process.env.ENCRYPTID_SERVER_URL || 'https://encryptid.jeffemmett.com';
/**
* Verify an EncryptID JWT token by calling the EncryptID server.
* 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 res = await fetch(`${ENCRYPTID_SERVER_URL}/api/session/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
const data = await res.json();
if (data.valid) {
return {
sub: data.userId,
username: data.username,
did: data.did,
exp: data.exp,
};
}
return null;
} catch {
return null;
}
}