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:
parent
23971b2f0f
commit
8276dacc24
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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'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'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">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue