diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d81b495..947d322 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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? diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx index 1c8051f..9ee9e62 100644 --- a/src/app/auth/signin/page.tsx +++ b/src/app/auth/signin/page.tsx @@ -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 ( Sign in - Enter your email and password to access your account + Sign in with a passkey or your email and password -
- + + {/* Passkey sign-in button */} + + +
+
+ +
+
+ Or continue with email +
+
+ + {/* Email/password form */} +
setEmail(e.target.value)} required - disabled={isLoading} + disabled={anyLoading} />
@@ -75,23 +191,23 @@ function SignInForm() { value={password} onChange={(e) => setPassword(e.target.value)} required - disabled={isLoading} + disabled={anyLoading} />
-
- - -

- Don't have an account?{" "} - - Sign up - -

-
- + +
+ +

+ Don't have an account?{" "} + + Sign up + +

+
); } diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx index ffbbc45..3e2eb27 100644 --- a/src/app/auth/signup/page.tsx +++ b/src/app/auth/signup/page.tsx @@ -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 (
@@ -80,19 +178,45 @@ export default function SignUpPage() { Join rVote to start ranking and voting on proposals -
- -
- - setName(e.target.value)} - disabled={isLoading} - /> + + {/* Passkey registration */} +
+ + setName(e.target.value)} + disabled={anyLoading} + /> +
+ + +
+
+
+
+ Or sign up with email +
+
+ + {/* Email/password form */} +
setEmail(e.target.value)} required - disabled={isLoading} + disabled={anyLoading} />
@@ -114,7 +238,7 @@ export default function SignUpPage() { value={password} onChange={(e) => setPassword(e.target.value)} required - disabled={isLoading} + disabled={anyLoading} />
@@ -125,26 +249,26 @@ export default function SignUpPage() { value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} required - disabled={isLoading} + disabled={anyLoading} />

You'll start with 50 credits and earn 10 more each day.

-
- - -

- Already have an account?{" "} - - Sign in - -

-
- + + + +

+ Already have an account?{" "} + + Sign in + +

+
); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 0e20212..5c2489a 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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 }) { diff --git a/src/lib/encryptid.ts b/src/lib/encryptid.ts new file mode 100644 index 0000000..5b494b6 --- /dev/null +++ b/src/lib/encryptid.ts @@ -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; + } +}