feat(auth): add passkey authentication support

This commit is contained in:
Nico 2026-04-28 19:18:49 +02:00 committed by Nicolas Meienberger
parent 273408cdb8
commit 98cff6709a
No known key found for this signature in database
10 changed files with 2876 additions and 3 deletions

View file

@ -7,6 +7,7 @@ import {
inferAdditionalFields,
} from "better-auth/client/plugins";
import { ssoClient } from "@better-auth/sso/client";
import { passkeyClient } from "@better-auth/passkey/client";
import type { auth } from "~/server/lib/auth";
export const authClient = createAuthClient({
@ -17,5 +18,6 @@ export const authClient = createAuthClient({
organizationClient(),
ssoClient(),
twoFactorClient(),
passkeyClient(),
],
});

View file

@ -1,5 +1,5 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { toast } from "sonner";
import { AuthLayout } from "~/client/components/auth-layout";
@ -50,6 +50,35 @@ export function LoginPage({ error }: LoginPageProps = {}) {
const errorCode = decodeLoginError(error);
const errorDescription = errorCode ? getLoginErrorDescription(errorCode) : null;
const passkeyAutofillStarted = useRef(false);
useEffect(() => {
if (passkeyAutofillStarted.current) return;
if (typeof window === "undefined") return;
const supportsConditional =
typeof window.PublicKeyCredential !== "undefined" &&
typeof window.PublicKeyCredential.isConditionalMediationAvailable === "function";
if (!supportsConditional) return;
passkeyAutofillStarted.current = true;
void (async () => {
try {
const available = await window.PublicKeyCredential.isConditionalMediationAvailable();
if (!available) return;
const { data, error } = await authClient.signIn.passkey({ autoFill: true });
if (error || !data) return;
const session = await authClient.getSession();
if (session.data?.user && !session.data.user.hasDownloadedResticPassword) {
void navigate({ to: "/download-recovery-key" });
} else {
void navigate({ to: "/volumes" });
}
} catch (err) {
logger.error(err);
}
})();
}, [navigate]);
const form = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: {
@ -227,7 +256,13 @@ export function LoginPage({ error }: LoginPageProps = {}) {
<FormItem>
<FormLabel>Username</FormLabel>
<FormControl>
<Input {...field} type="text" placeholder="admin" disabled={isLoggingIn} />
<Input
{...field}
type="text"
placeholder="admin"
disabled={isLoggingIn}
autoComplete="username webauthn"
/>
</FormControl>
<FormMessage />
</FormItem>
@ -249,7 +284,7 @@ export function LoginPage({ error }: LoginPageProps = {}) {
</button>
</div>
<FormControl>
<Input {...field} type="password" disabled={isLoggingIn} />
<Input {...field} type="password" disabled={isLoggingIn} autoComplete="current-password webauthn" />
</FormControl>
<FormMessage />
</FormItem>

View file

@ -0,0 +1,289 @@
import { useState } from "react";
import { Fingerprint, Plus, Trash2, Pencil } from "lucide-react";
import { toast } from "sonner";
import { Button } from "~/client/components/ui/button";
import { CardContent, CardDescription, CardTitle } from "~/client/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/client/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/client/components/ui/dialog";
import { Input } from "~/client/components/ui/input";
import { Label } from "~/client/components/ui/label";
import { authClient } from "~/client/lib/auth-client";
import { logger } from "~/client/lib/logger";
import { useTimeFormat } from "~/client/lib/datetime";
type PasskeyEntry = {
id: string;
name?: string | null;
createdAt: Date | string;
deviceType?: string;
};
export function PasskeysSection() {
const { formatDateTime } = useTimeFormat();
const { data: passkeys, isPending, refetch } = authClient.useListPasskeys();
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [newPasskeyName, setNewPasskeyName] = useState("");
const [isAdding, setIsAdding] = useState(false);
const [renameTarget, setRenameTarget] = useState<PasskeyEntry | null>(null);
const [renameValue, setRenameValue] = useState("");
const [isRenaming, setIsRenaming] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<PasskeyEntry | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const handleAddPasskey = async (e: React.FormEvent) => {
e.preventDefault();
setIsAdding(true);
try {
const { error } = await authClient.passkey.addPasskey({
name: newPasskeyName.trim() || undefined,
});
if (error) {
logger.error(error);
toast.error("Failed to add passkey", { description: error.message });
return;
}
toast.success("Passkey added");
setAddDialogOpen(false);
setNewPasskeyName("");
await refetch();
} catch (err) {
logger.error(err);
toast.error("Failed to add passkey", {
description: err instanceof Error ? err.message : "Unknown error",
});
} finally {
setIsAdding(false);
}
};
const handleRename = async (e: React.FormEvent) => {
e.preventDefault();
if (!renameTarget) return;
const name = renameValue.trim();
if (!name) {
toast.error("Name is required");
return;
}
setIsRenaming(true);
const { error } = await authClient.$fetch("/passkey/update-passkey", {
method: "POST",
body: { id: renameTarget.id, name },
});
setIsRenaming(false);
if (error) {
logger.error(error);
toast.error("Failed to rename passkey", { description: error.message });
return;
}
toast.success("Passkey renamed");
setRenameTarget(null);
setRenameValue("");
await refetch();
};
const handleDelete = async () => {
if (!deleteTarget) return;
setIsDeleting(true);
const { error } = await authClient.$fetch("/passkey/delete-passkey", {
method: "POST",
body: { id: deleteTarget.id },
});
setIsDeleting(false);
if (error) {
logger.error(error);
toast.error("Failed to delete passkey", { description: error.message });
return;
}
toast.success("Passkey deleted");
setDeleteTarget(null);
await refetch();
};
const list = (passkeys ?? []) as PasskeyEntry[];
return (
<>
<div className="border-t border-border/50 bg-card-header p-6">
<CardTitle className="flex items-center gap-2">
<Fingerprint className="size-5" />
Passkeys
</CardTitle>
<CardDescription className="mt-1.5">
Sign in faster and more securely with passkeys stored on your device or password manager. You can add more
than one.
</CardDescription>
</div>
<CardContent className="p-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<p className="text-xs text-muted-foreground max-w-xl">
Passkeys use your device's biometrics or screen lock instead of a password. They are phishing-resistant and
cannot be reused across sites.
</p>
<Button onClick={() => setAddDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Add passkey
</Button>
</div>
{isPending ? (
<p className="text-sm text-muted-foreground">Loading passkeys...</p>
) : list.length === 0 ? (
<p className="text-sm text-muted-foreground">No passkeys yet. Add one to enable passwordless sign-in.</p>
) : (
<ul className="divide-y divide-border/50 rounded-md border border-border/50">
{list.map((p) => (
<li key={p.id} className="flex items-center justify-between gap-4 p-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{p.name?.trim() || "Unnamed passkey"}</p>
<p className="text-xs text-muted-foreground">Added {formatDateTime(new Date(p.createdAt))}</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
setRenameTarget(p);
setRenameValue(p.name ?? "");
}}
>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="destructive" size="sm" onClick={() => setDeleteTarget(p)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</li>
))}
</ul>
)}
</CardContent>
<Dialog
open={addDialogOpen}
onOpenChange={(open) => {
setAddDialogOpen(open);
if (!open) setNewPasskeyName("");
}}
>
<DialogContent>
<form onSubmit={handleAddPasskey}>
<DialogHeader>
<DialogTitle>Add a passkey</DialogTitle>
<DialogDescription>
Give this passkey a name so you can recognize it later (e.g. "MacBook Touch ID").
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="passkey-name">Name (optional)</Label>
<Input
id="passkey-name"
value={newPasskeyName}
onChange={(e) => setNewPasskeyName(e.target.value)}
placeholder="My Laptop"
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setAddDialogOpen(false)}>
Cancel
</Button>
<Button type="submit" loading={isAdding}>
<Fingerprint className="h-4 w-4 mr-2" />
Add passkey
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(renameTarget)}
onOpenChange={(open) => {
if (!open) {
setRenameTarget(null);
setRenameValue("");
}
}}
>
<DialogContent>
<form onSubmit={handleRename}>
<DialogHeader>
<DialogTitle>Rename passkey</DialogTitle>
<DialogDescription>Choose a name to recognize this passkey.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="passkey-rename">Name</Label>
<Input
id="passkey-rename"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
autoFocus
required
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setRenameTarget(null)}>
Cancel
</Button>
<Button type="submit" loading={isRenaming}>
Save
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => {
if (!open) setDeleteTarget(null);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete passkey?</AlertDialogTitle>
<AlertDialogDescription>
This will remove "{deleteTarget?.name?.trim() || "this passkey"}" from your account. You won't be able to
use it to sign in anymore.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={(e) => {
e.preventDefault();
void handleDelete();
}}
disabled={isDeleting}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View file

@ -42,6 +42,7 @@ import { logger } from "~/client/lib/logger";
import { parseError } from "~/client/lib/errors";
import { type AppContext } from "~/context";
import { TwoFactorSection } from "../components/two-factor-section";
import { PasskeysSection } from "../components/passkeys-section";
import { useNavigate, useSearch } from "@tanstack/react-router";
import { SsoSettingsSection } from "~/client/modules/sso/components/sso-settings-section";
import { OrgMembersSection } from "../components/org-members-section";
@ -458,6 +459,8 @@ export function SettingsPage({ appContext, initialMembers, initialSsoSettings, i
</CardContent>
<TwoFactorSection twoFactorEnabled={appContext.user?.twoFactorEnabled} />
<PasskeysSection />
</Card>
</TabsContent>

View file

@ -0,0 +1,17 @@
CREATE TABLE `passkey` (
`id` text PRIMARY KEY,
`name` text,
`public_key` text NOT NULL,
`user_id` text NOT NULL,
`credential_id` text NOT NULL,
`counter` integer NOT NULL,
`device_type` text NOT NULL,
`backed_up` integer NOT NULL,
`transports` text,
`created_at` integer DEFAULT (unixepoch() * 1000) NOT NULL,
`aaguid` text,
CONSTRAINT `fk_passkey_user_id_users_table_id_fk` FOREIGN KEY (`user_id`) REFERENCES `users_table`(`id`) ON DELETE CASCADE
);
--> statement-breakpoint
CREATE INDEX `passkey_userId_idx` ON `passkey` (`user_id`);--> statement-breakpoint
CREATE INDEX `passkey_credentialID_idx` ON `passkey` (`credential_id`);

File diff suppressed because it is too large Load diff

View file

@ -76,6 +76,7 @@ export const relations = defineRelations(schema, (r) => ({
sessions: r.many.sessionsTable(),
members: r.many.member(),
twoFactors: r.many.twoFactor(),
passkeys: r.many.passkey(),
ssoProviders: r.many.ssoProvider(),
organizations: r.many.organization({
from: r.usersTable.id.through(r.member.userId),
@ -95,6 +96,12 @@ export const relations = defineRelations(schema, (r) => ({
to: r.usersTable.id,
}),
},
passkey: {
usersTable: r.one.usersTable({
from: r.passkey.userId,
to: r.usersTable.id,
}),
},
organization: {
users: r.many.usersTable({
alias: "usersTable_id_organization_id_via_member",

View file

@ -508,3 +508,26 @@ export const twoFactor = sqliteTable(
},
(table) => [index("twoFactor_secret_idx").on(table.secret), index("twoFactor_userId_idx").on(table.userId)],
);
export const passkey = sqliteTable(
"passkey",
{
id: text("id").primaryKey(),
name: text("name"),
publicKey: text("public_key").notNull(),
userId: text("user_id")
.notNull()
.references(() => usersTable.id, { onDelete: "cascade" }),
credentialID: text("credential_id").notNull(),
counter: integer("counter").notNull(),
deviceType: text("device_type").notNull(),
backedUp: integer("backed_up", { mode: "boolean" }).notNull(),
transports: text("transports"),
createdAt: int("created_at", { mode: "timestamp_ms" })
.notNull()
.default(sql`(unixepoch() * 1000)`),
aaguid: text("aaguid"),
},
(table) => [index("passkey_userId_idx").on(table.userId), index("passkey_credentialID_idx").on(table.credentialID)],
);
export type Passkey = typeof passkey.$inferSelect;

View file

@ -8,6 +8,7 @@ import {
import { APIError } from "better-auth/api";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { admin, twoFactor, username, organization, testUtils } from "better-auth/plugins";
import { passkey } from "@better-auth/passkey";
import { createAuthMiddleware } from "better-auth/api";
import { config } from "../core/config";
import { db } from "../db/db";
@ -170,6 +171,10 @@ export const auth = betterAuth({
amount: 5,
},
}),
passkey({
rpID: new URL(config.baseUrl).hostname,
rpName: "Zerobyte",
}),
tanstackStartCookies(),
...(process.env.NODE_ENV === "test" ? [testUtils()] : []),
],

View file

@ -36,6 +36,7 @@
"test:codegen": "playwright codegen localhost:4096"
},
"dependencies": {
"@better-auth/passkey": "1.5.5",
"@better-auth/sso": "^1.6.11",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",