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:
parent
90865039f5
commit
f48a98d520
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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'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't have an account?{" "}
|
||||
<Link href="/auth/signup" className="text-primary hover:underline">
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue