Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions control-plane/drizzle/0016_passkeys.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS "passkey" (
"id" text PRIMARY KEY NOT NULL,
"name" text,
"public_key" text NOT NULL,
"user_id" text NOT NULL REFERENCES "user"("id") ON DELETE CASCADE,
"credential_id" text NOT NULL UNIQUE,
"counter" integer NOT NULL,
"device_type" text NOT NULL,
"backed_up" boolean NOT NULL,
"transports" text,
"created_at" timestamp with time zone,
"aaguid" text
);
3 changes: 2 additions & 1 deletion control-plane/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@
"test": "vitest run"
},
"dependencies": {
"@better-auth/passkey": "^1.7.14",
"@node-rs/argon2": "^2.0.2",
"@types/geoip-lite": "^1.4.4",
"better-auth": "^1.6.25",
"better-auth": "^1.7.14",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
Expand Down
14 changes: 14 additions & 0 deletions control-plane/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,20 @@ export const verification = pgTable("verification", {
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow(),
});

export const passkey = pgTable("passkey", {
id: text("id").primaryKey(),
name: text("name"),
publicKey: text("public_key").notNull(),
userId: text("user_id").notNull().references(() => user.id, { onDelete: "cascade" }),
credentialID: text("credential_id").notNull().unique(),
counter: integer("counter").notNull(),
deviceType: text("device_type").notNull(),
backedUp: boolean("backed_up").notNull(),
transports: text("transports"),
createdAt: timestamp("created_at", { withTimezone: true }),
aaguid: text("aaguid"),
});

export const accountTokens = pgTable(
"account_tokens",
{
Expand Down
8 changes: 8 additions & 0 deletions control-plane/src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { customSession } from "better-auth/plugins";
import { passkey } from "@better-auth/passkey";
import { eq } from "drizzle-orm";
import { db } from "../db/client.js";
import * as schema from "../db/schema.js";
Expand Down Expand Up @@ -28,6 +29,7 @@ export const auth = betterAuth({
session: schema.session,
account: schema.account,
verification: schema.verification,
passkey: schema.passkey,
},
}),
account: {
Expand Down Expand Up @@ -117,6 +119,12 @@ export const auth = betterAuth({
},
},
plugins: [
passkey({
rpID: new URL(process.env.BETTER_AUTH_URL!).hostname,
rpName: "tunl",
origin: process.env.DASHBOARD_URL!,
authenticatorSelection: { residentKey: "required", userVerification: "required" },
}),
customSession(async ({ user, session }) => {
let userPlan = { name: "free", maxReservedSubdomains: 1 };
const rawUser = user as Record<string, any>;
Expand Down
16 changes: 15 additions & 1 deletion dashboard/app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Label } from "@/components/ui/label";
import { authClient } from "@/lib/auth-client";
import { useAuth } from "@/lib/auth-context";
import { zodResolver } from "@hookform/resolvers/zod";
import { Eye, EyeOff, Loader2, Mail } from "lucide-react";
import { Eye, EyeOff, Loader2, Mail, Fingerprint } from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
Expand Down Expand Up @@ -237,6 +237,20 @@ export default function LoginPage() {
</Button>
</div>

<Button type="button" variant="outline" disabled={isSubmitting || !!isSocialLoading} onClick={async () => {
setIsSubmitting(true);
try {
const result = await authClient.signIn.passkey();
if (result.error) throw new Error(result.error.message || "Passkey sign-in failed");
saveLastUsed("passkey");
router.push("/dashboard");
} catch (err: any) {
toast.error(err?.message || "Passkey sign-in failed. Try another sign-in method.");
} finally { setIsSubmitting(false); }
}} className="h-9 w-full gap-2 text-xs font-semibold">
<Fingerprint className="h-4 w-4" /> Sign in with a passkey
</Button>

<div className="relative flex items-center justify-center">
<div className="border-border/60 w-full border-t" />
<span className="bg-background text-muted-foreground absolute flex items-center gap-1 px-2 font-sans text-[11px] font-medium tracking-wider uppercase">
Expand Down
3 changes: 3 additions & 0 deletions dashboard/app/(dashboard)/profile/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useAuth } from "@/lib/auth-context";
import { ProfileInfoCard } from "@/components/profile/profile-info-card";
import { TwoFactorCard } from "@/components/profile/two-factor-card";
import { IpWhitelistCard } from "@/components/profile/ip-whitelist-card";
import { PasskeysCard } from "@/components/profile/passkeys-card";
import { AlertCircle, Loader2 } from "lucide-react";

export default function ProfilePage() {
Expand Down Expand Up @@ -74,6 +75,8 @@ export default function ProfilePage() {
isDisablePending={disable2FA.isPending}
/>

<PasskeysCard />

{/* IP Whitelist Security Card */}
<IpWhitelistCard
key={`${profile.ipWhitelistEnabled}-${profile.allowedIps?.join(",")}`}
Expand Down
52 changes: 52 additions & 0 deletions dashboard/components/profile/passkeys-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"use client";

import { useCallback, useEffect, useState } from "react";
import { authClient } from "@/lib/auth-client";
import { Button } from "@/components/ui/button";
import { Fingerprint, Loader2, Trash2 } from "lucide-react";
import { toast } from "sonner";

type Passkey = { id: string; name?: string | null; createdAt?: Date | string | null; deviceType?: string };

export function PasskeysCard() {
const [items, setItems] = useState<Passkey[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const { data, error } = await authClient.passkey.listUserPasskeys();
if (error) throw new Error(error.message);
setItems((data ?? []) as Passkey[]);
} catch (error) { toast.error(error instanceof Error ? error.message : "Could not load passkeys"); }
finally { setLoading(false); }
}, []);
useEffect(() => { void refresh(); }, [refresh]);
const add = async () => {
const name = window.prompt("Name this passkey", "My passkey");
if (name === null) return;
setBusy(true);
try {
const { error } = await authClient.passkey.addPasskey({ name: name.trim().slice(0, 64) || "Passkey" });
if (error) throw new Error(error.message);
toast.success("Passkey added"); await refresh();
} catch (error) { toast.error(error instanceof Error ? error.message : "Passkey registration failed"); }
finally { setBusy(false); }
};
const remove = async (item: Passkey) => {
if (items.length === 1 && !window.confirm("This is your only passkey. Remove it? You will need another sign-in method.")) return;
if (!window.confirm(`Remove “${item.name || "Passkey"}” from your account?`)) return;
setBusy(true);
try { const { error } = await authClient.passkey.deletePasskey({ id: item.id }); if (error) throw new Error(error.message); toast.success("Passkey removed"); await refresh(); }
catch (error) { toast.error(error instanceof Error ? error.message : "Could not remove passkey"); }
finally { setBusy(false); }
};
return <section className="border-border/60 bg-card overflow-hidden rounded-lg border font-sans">
<header className="border-border/60 bg-muted/40 flex items-center gap-2 border-b px-4 py-3 text-xs font-semibold"><Fingerprint className="text-emerald-400 h-4 w-4"/>Passkeys</header>
<div className="space-y-3 p-5 text-xs">
<p className="text-muted-foreground">Use your device biometrics, PIN, or a security key to sign in securely.</p>
{loading ? <Loader2 className="text-muted-foreground h-4 w-4 animate-spin"/> : items.length === 0 ? <p className="text-muted-foreground">No passkeys registered yet.</p> : items.map((item) => <div key={item.id} className="border-border/60 flex items-center justify-between rounded-md border p-3"><div><p className="text-foreground font-medium">{item.name || "Passkey"}</p><p className="text-muted-foreground mt-1">{item.deviceType || "Authenticator"}{item.createdAt ? ` · Added ${new Date(item.createdAt).toLocaleDateString()}` : ""}</p></div><Button variant="outline" size="sm" disabled={busy} aria-label={`Remove ${item.name || "passkey"}`} onClick={() => void remove(item)}><Trash2 className="text-destructive h-4 w-4"/></Button></div>)}
<Button size="sm" disabled={busy || loading} onClick={() => void add()}>{busy && <Loader2 className="mr-2 h-3 w-3 animate-spin"/>}Add a passkey</Button>
</div>
</section>;
}
2 changes: 2 additions & 0 deletions dashboard/lib/auth-client.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createAuthClient } from "better-auth/react";
import { passkeyClient } from "@better-auth/passkey/client";

const getAuthBaseUrl = () => {
const url = process.env.NEXT_PUBLIC_CONTROL_PLANE_URL;
Expand All @@ -11,6 +12,7 @@ const getAuthBaseUrl = () => {

export const authClient = createAuthClient({
baseURL: getAuthBaseUrl(),
plugins: [passkeyClient()],
});

export const { signIn, signUp, signOut, useSession, getSession } = authClient;
5 changes: 3 additions & 2 deletions dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@
},
"dependencies": {
"@base-ui/react": "^1.6.0",
"@hookform/resolvers": "5.2.0",
"@better-auth/passkey": "^1.7.14",
"@hookform/resolvers": "6.0.2",
"@shadcn/react": "^0.2.1",
"@tanstack/react-query": "^5.101.2",
"axios": "^1.18.1",
"better-auth": "^1.6.25",
"better-auth": "^1.7.14",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
Expand Down
Loading
Loading