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
|
email String @unique
|
||||||
passwordHash String?
|
passwordHash String?
|
||||||
name String?
|
name String?
|
||||||
|
did String? @unique // EncryptID DID (passkey identity)
|
||||||
credits Int @default(0)
|
credits Int @default(0)
|
||||||
lastCreditAt DateTime @default(now())
|
lastCreditAt DateTime @default(now())
|
||||||
emailVerified DateTime?
|
emailVerified DateTime?
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,11 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
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";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const ENCRYPTID_SERVER = process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL || "https://encryptid.jeffemmett.com";
|
||||||
|
|
||||||
function SignInForm() {
|
function SignInForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
@ -19,6 +21,7 @@ function SignInForm() {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
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 (
|
return (
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader className="space-y-1">
|
<CardHeader className="space-y-1">
|
||||||
<CardTitle className="text-2xl font-bold">Sign in</CardTitle>
|
<CardTitle className="text-2xl font-bold">Sign in</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Enter your email and password to access your account
|
Sign in with a passkey or your email and password
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -64,7 +180,7 @@ function SignInForm() {
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
disabled={isLoading}
|
disabled={anyLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -75,23 +191,23 @@ function SignInForm() {
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
disabled={isLoading}
|
disabled={anyLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
<Button type="submit" className="w-full" disabled={anyLoading}>
|
||||||
<CardFooter className="flex flex-col space-y-4">
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
|
||||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
Sign in
|
Sign in
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
</form>
|
||||||
Don't have an account?{" "}
|
</CardContent>
|
||||||
<Link href="/auth/signup" className="text-primary hover:underline">
|
<CardFooter>
|
||||||
Sign up
|
<p className="text-sm text-muted-foreground text-center w-full">
|
||||||
</Link>
|
Don't have an account?{" "}
|
||||||
</p>
|
<Link href="/auth/signup" className="text-primary hover:underline">
|
||||||
</CardFooter>
|
Sign up
|
||||||
</form>
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,11 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
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";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const ENCRYPTID_SERVER = process.env.NEXT_PUBLIC_ENCRYPTID_SERVER_URL || "https://encryptid.jeffemmett.com";
|
||||||
|
|
||||||
export default function SignUpPage() {
|
export default function SignUpPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
|
@ -19,6 +21,7 @@ export default function SignUpPage() {
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [confirmPassword, setConfirmPassword] = useState("");
|
const [confirmPassword, setConfirmPassword] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
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 (
|
return (
|
||||||
<div className="flex min-h-[60vh] items-center justify-center">
|
<div className="flex min-h-[60vh] items-center justify-center">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md">
|
||||||
|
|
@ -80,19 +178,45 @@ export default function SignUpPage() {
|
||||||
Join rVote to start ranking and voting on proposals
|
Join rVote to start ranking and voting on proposals
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<form onSubmit={handleSubmit}>
|
<CardContent className="space-y-4">
|
||||||
<CardContent className="space-y-4">
|
{/* Passkey registration */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Name (optional)</Label>
|
<Label htmlFor="passkey-name">Display Name</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="passkey-name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Your name"
|
placeholder="Your name"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
disabled={isLoading}
|
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>
|
||||||
|
<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">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="email">Email</Label>
|
<Label htmlFor="email">Email</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|
@ -102,7 +226,7 @@ export default function SignUpPage() {
|
||||||
value={email}
|
value={email}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
required
|
||||||
disabled={isLoading}
|
disabled={anyLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -114,7 +238,7 @@ export default function SignUpPage() {
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
disabled={isLoading}
|
disabled={anyLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -125,26 +249,26 @@ export default function SignUpPage() {
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
disabled={isLoading}
|
disabled={anyLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
You'll start with <strong>50 credits</strong> and earn 10 more each day.
|
You'll start with <strong>50 credits</strong> and earn 10 more each day.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
<Button type="submit" className="w-full" disabled={anyLoading}>
|
||||||
<CardFooter className="flex flex-col space-y-4">
|
|
||||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
|
||||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
Create account
|
Create account
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-sm text-muted-foreground text-center">
|
</form>
|
||||||
Already have an account?{" "}
|
</CardContent>
|
||||||
<Link href="/auth/signin" className="text-primary hover:underline">
|
<CardFooter>
|
||||||
Sign in
|
<p className="text-sm text-muted-foreground text-center w-full">
|
||||||
</Link>
|
Already have an account?{" "}
|
||||||
</p>
|
<Link href="/auth/signin" className="text-primary hover:underline">
|
||||||
</CardFooter>
|
Sign in
|
||||||
</form>
|
</Link>
|
||||||
|
</p>
|
||||||
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import Credentials from "next-auth/providers/credentials";
|
||||||
import { PrismaAdapter } from "@auth/prisma-adapter";
|
import { PrismaAdapter } from "@auth/prisma-adapter";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
|
import { verifyEncryptIDToken } from "./encryptid";
|
||||||
|
|
||||||
export const { handlers, signIn, signOut, auth } = NextAuth({
|
export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
adapter: PrismaAdapter(prisma),
|
adapter: PrismaAdapter(prisma),
|
||||||
|
|
@ -15,7 +16,9 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
|
||||||
error: "/auth/error",
|
error: "/auth/error",
|
||||||
},
|
},
|
||||||
providers: [
|
providers: [
|
||||||
|
// Email + password login
|
||||||
Credentials({
|
Credentials({
|
||||||
|
id: "credentials",
|
||||||
name: "credentials",
|
name: "credentials",
|
||||||
credentials: {
|
credentials: {
|
||||||
email: { label: "Email", type: "email" },
|
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: {
|
callbacks: {
|
||||||
async jwt({ token, user }) {
|
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