feat(auth): add passkey authentication support
This commit is contained in:
parent
273408cdb8
commit
98cff6709a
10 changed files with 2876 additions and 3 deletions
|
|
@ -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(),
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
289
app/client/modules/settings/components/passkeys-section.tsx
Normal file
289
app/client/modules/settings/components/passkeys-section.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
17
app/drizzle/20260428161759_productive_namor/migration.sql
Normal file
17
app/drizzle/20260428161759_productive_namor/migration.sql
Normal 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`);
|
||||
2491
app/drizzle/20260428161759_productive_namor/snapshot.json
Normal file
2491
app/drizzle/20260428161759_productive_namor/snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()] : []),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue