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
19 changes: 11 additions & 8 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

32 changes: 32 additions & 0 deletions db/migrations/20260519000000_passkey_table.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
-- migrate:up

-- Adds the `passkey` table required by @better-auth/passkey. Each row is a
-- WebAuthn credential: bound to a user, identified by `credential_id` (the
-- credential's external id the browser reports), with `public_key` used to
-- verify subsequent authentication assertions. `counter` is the WebAuthn
-- signCount used to detect cloned authenticators.
--
-- Stored separately from `account` because account.providerId is unique-per-
-- user-per-provider, but a user can have many passkeys (one per
-- device/browser).

CREATE TABLE IF NOT EXISTS "passkey" (
id TEXT PRIMARY KEY,
name TEXT,
"publicKey" TEXT NOT NULL,
"userId" TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
"credentialID" TEXT NOT NULL,
counter BIGINT NOT NULL DEFAULT 0,
"deviceType" TEXT NOT NULL,
"backedUp" BOOLEAN NOT NULL DEFAULT FALSE,
transports TEXT,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aaguid TEXT
);

CREATE INDEX IF NOT EXISTS passkey_user_id_idx ON "passkey"("userId");
CREATE INDEX IF NOT EXISTS passkey_credential_id_idx ON "passkey"("credentialID");

Comment on lines +18 to +29
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Enforce uniqueness for credentialID.

credentialID is currently only indexed, not constrained unique. Duplicate credential IDs can make passkey lookup ambiguous during authentication.

Suggested migration adjustment
-CREATE INDEX IF NOT EXISTS passkey_credential_id_idx ON "passkey"("credentialID");
+CREATE UNIQUE INDEX IF NOT EXISTS passkey_credential_id_uq ON "passkey"("credentialID");
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"credentialID" TEXT NOT NULL,
counter BIGINT NOT NULL DEFAULT 0,
"deviceType" TEXT NOT NULL,
"backedUp" BOOLEAN NOT NULL DEFAULT FALSE,
transports TEXT,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aaguid TEXT
);
CREATE INDEX IF NOT EXISTS passkey_user_id_idx ON "passkey"("userId");
CREATE INDEX IF NOT EXISTS passkey_credential_id_idx ON "passkey"("credentialID");
"credentialID" TEXT NOT NULL,
counter BIGINT NOT NULL DEFAULT 0,
"deviceType" TEXT NOT NULL,
"backedUp" BOOLEAN NOT NULL DEFAULT FALSE,
transports TEXT,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT NOW(),
aaguid TEXT
);
CREATE INDEX IF NOT EXISTS passkey_user_id_idx ON "passkey"("userId");
CREATE UNIQUE INDEX IF NOT EXISTS passkey_credential_id_uq ON "passkey"("credentialID");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@db/migrations/20260519000000_passkey_table.sql` around lines 18 - 29, The
passkey table defines "credentialID" without a uniqueness constraint, risking
duplicate credentials; modify the migration to enforce uniqueness by altering
the table definition or adding a unique index on "credentialID" (replace or
convert the existing passkey_credential_id_idx to a UNIQUE index) so that
"credentialID" is unique across the "passkey" table and migrations will fail if
duplicates exist.

-- migrate:down

DROP TABLE IF EXISTS "passkey";
45 changes: 45 additions & 0 deletions db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -1545,6 +1545,24 @@ CREATE TABLE public.organization_lobu_links (
updated_at timestamp with time zone DEFAULT now() NOT NULL
);

--
-- Name: passkey; Type: TABLE; Schema: public; Owner: -
--

CREATE TABLE public.passkey (
id text NOT NULL,
name text,
"publicKey" text NOT NULL,
"userId" text NOT NULL,
"credentialID" text NOT NULL,
counter bigint DEFAULT 0 NOT NULL,
"deviceType" text NOT NULL,
"backedUp" boolean DEFAULT false NOT NULL,
transports text,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

What is the expected data type for the transports column in the @better-auth/passkey schema - TEXT, text array, or jsonb?

💡 Result:

The expected data type for the transports column in the @better-auth/passkey schema is a text array (Postgres type: text[]). Evidence: Better Auth’s passkey plugin documentation describes transports as “The transports used to register the passkey” in the generated passkey table schema [1]. The underlying Better Auth schema type definition for the passkey plugin declares transports as an array of strings, which maps to a Postgres text[] column in the generated schema [2].

Citations:


Change transports column type from TEXT to text[].

The @better-auth/passkey schema expects transports as a Postgres array type (text[]) to store the list of WebAuthn transports (e.g., ["usb", "nfc", "ble"]), not a scalar TEXT column. This is required for the plugin to correctly handle transport data.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@db/schema.sql` at line 1561, Change the transports column from a scalar TEXT
to a Postgres text[] array to match the `@better-auth/passkey` expectation: locate
the column definition named "transports" in the schema (currently "transports
text,") and update it to use the text[] type; also update any related DEFAULTs,
INSERTs, or CHECK constraints to use array syntax and add a small migration step
to convert existing scalar values into single-element arrays (or NULL) so
existing data remains valid.

"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
aaguid text
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment on lines +1552 to +1564
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

Does the @better-auth/passkey plugin require a unique constraint on the credentialID column in the passkey table schema?

💡 Result:

Yes, the @better-auth/passkey plugin requires the credentialID column to be unique [1]. In the official schema definition for the passkey table, the credentialID is identified as the unique identifier for the registered credential [2][1]. While the plugin's documentation and schema generation tools (such as the CLI migrate or generate commands) handle the creation of the necessary table structure [3][4][1], it is standard practice and a functional requirement for the credentialID to be unique to ensure that each passkey can be correctly identified and retrieved during the authentication ceremony [2][1]. If you are defining the schema manually, you should ensure that the credentialID column has a unique constraint applied to it [1].

Citations:


Add UNIQUE constraint on credentialID column.

The credentialID column stores the WebAuthn credential identifier, which must be globally unique per the WebAuthn specification and the @better-auth/passkey plugin requirements. During authentication, the server looks up credentials by this ID; without a UNIQUE constraint, duplicate credential IDs could be stored, breaking the authentication flow.

Proposed fix

Add a unique index on credentialID. After the existing non-unique index at line 4325:

 CREATE INDEX passkey_credential_id_idx ON public.passkey USING btree ("credentialID");
+CREATE UNIQUE INDEX passkey_credential_id_key ON public.passkey USING btree ("credentialID");

Alternatively, add the constraint via ALTER TABLE after the primary key constraint at line 2875:

 ALTER TABLE ONLY public.passkey
     ADD CONSTRAINT passkey_pkey PRIMARY KEY (id);
+ALTER TABLE ONLY public.passkey
+    ADD CONSTRAINT passkey_credential_id_key UNIQUE ("credentialID");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@db/schema.sql` around lines 1552 - 1564, The passkey table lacks a uniqueness
constraint on the credentialID column which must be globally unique; add a
UNIQUE constraint/index for "credentialID" on the passkey table (referencing the
passkey table and its "credentialID" column) — either create a unique index on
passkey("credentialID") or run an ALTER TABLE passkey ADD CONSTRAINT ...
UNIQUE("credentialID") after the primary key is defined so duplicate credential
IDs cannot be inserted.


--
-- Name: pending_interactions; Type: TABLE; Schema: public; Owner: -
--
Expand Down Expand Up @@ -2849,6 +2867,13 @@ ALTER TABLE ONLY public.organization
ALTER TABLE ONLY public.organization
ADD CONSTRAINT organization_slug_key UNIQUE (slug);

--
-- Name: passkey passkey_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--

ALTER TABLE ONLY public.passkey
ADD CONSTRAINT passkey_pkey PRIMARY KEY (id);

--
-- Name: pending_interactions pending_interactions_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
Expand Down Expand Up @@ -4293,6 +4318,18 @@ CREATE INDEX oauth_tokens_token_hash_idx ON public.oauth_tokens USING btree (tok

CREATE INDEX oauth_tokens_user_id_idx ON public.oauth_tokens USING btree (user_id);

--
-- Name: passkey_credential_id_idx; Type: INDEX; Schema: public; Owner: -
--

CREATE INDEX passkey_credential_id_idx ON public.passkey USING btree ("credentialID");

--
-- Name: passkey_user_id_idx; Type: INDEX; Schema: public; Owner: -
--

CREATE INDEX passkey_user_id_idx ON public.passkey USING btree ("userId");

--
-- Name: personal_access_tokens_active_idx; Type: INDEX; Schema: public; Owner: -
--
Expand Down Expand Up @@ -5066,6 +5103,13 @@ ALTER TABLE ONLY public.organization_lobu_links
ALTER TABLE ONLY public.organization_lobu_links
ADD CONSTRAINT organization_lobu_links_organization_id_fkey FOREIGN KEY (organization_id) REFERENCES public.organization(id) ON DELETE CASCADE;

--
-- Name: passkey passkey_userId_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--

ALTER TABLE ONLY public.passkey
ADD CONSTRAINT "passkey_userId_fkey" FOREIGN KEY ("userId") REFERENCES public."user"(id) ON DELETE CASCADE;

--
-- Name: pending_interactions pending_interactions_organization_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
--
Expand Down Expand Up @@ -5343,5 +5387,6 @@ INSERT INTO public.schema_migrations (version) VALUES
('20260518050000'),
('20260518060000'),
('20260518070000'),
('20260519000000'),
('20260519020000'),
('20260519020001');
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@
"@sinclair/typebox": "^0.34.41",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"@better-auth/passkey": "^1.6.9",
"better-auth": "^1.4.10",
"chalk": "^5.3.0",
"chat": "^4.26.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/owletto
1 change: 1 addition & 0 deletions packages/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"@sinclair/typebox": "^0.34.41",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"@better-auth/passkey": "^1.6.9",
"better-auth": "^1.4.10",
"chat": "^4.26.0",
"commander": "^14.0.1",
Expand Down
33 changes: 32 additions & 1 deletion packages/server/src/auth/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ interface AuthConfig {
magicLink: boolean;
phone: boolean;
emailPassword: boolean;
passkey: boolean;
// True iff this deployment runs in single-user mode (LOBU_SINGLE_USER=1).
// The SPA branches signup/sign-in copy on this — "Set up your local install"
// vs "Sign up for Lobu" — and skips affordances that don't apply.
singleUserMode: boolean;
// True iff at least one (non-legacy-bootstrap) user already exists. The SPA
// routes `/` → /sign-up when this is false in single-user mode, so the
// operator lands on the right page on first launch without typing a URL.
hasUser: boolean;
}

type TokenEndpointAuthMethod = 'client_secret_post' | 'client_secret_basic' | 'none';
Expand Down Expand Up @@ -400,6 +409,28 @@ export async function getAuthConfig(
const hasProviderAuthEnabled = Object.values(social).some(Boolean) || phone;
const emailPassword =
hasValue(env.BETTER_AUTH_SECRET) || (!isProduction && !hasProviderAuthEnabled);
// Passkey plugin is always wired (auth/index.tsx) — the gateway can verify
// WebAuthn ceremonies regardless of env config.
const passkey = true;
const singleUserMode = env.LOBU_SINGLE_USER === '1';
// Filter out the legacy bootstrap-user (pre-PR #902) — it doesn't count as
// "the install has a user." Real users include anyone signed up via the web
// UI after that PR.
let hasUser = false;
try {
const sql = getDb();
const rows = (await sql`
SELECT EXISTS(
SELECT 1 FROM "user" WHERE id <> 'bootstrap-user'
) AS has_user
`) as unknown as Array<{ has_user: boolean }>;
hasUser = !!rows[0]?.has_user;
} catch {
// If the DB isn't reachable (very early boot / migrations still running),
// fail closed: treat as "no user yet" so the SPA shows /sign-up. Worst
// case is one extra page transition when the operator clicks something.
hasUser = false;
}

return { social, magicLink, phone, emailPassword };
return { social, magicLink, phone, emailPassword, passkey, singleUserMode, hasUser };
}
36 changes: 36 additions & 0 deletions packages/server/src/auth/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createHash } from "node:crypto";
import { passkey } from "@better-auth/passkey";
import { APIError, betterAuth } from "better-auth";
import { magicLink, organization, phoneNumber } from "better-auth/plugins";
import { bearer } from "better-auth/plugins/bearer";
Expand Down Expand Up @@ -513,6 +514,41 @@ export async function createAuth(env: Env, request?: Request) {
otpLength: 6,
expiresIn: 60 * 5, // 5 minutes
}),
// WebAuthn / passkey support. Especially useful in local-mode
// (LOBU_SINGLE_USER=1) where Touch ID / Face ID is a much cleaner
// auth than "remember the password you typed at /sign-up." Default
// `requireSession: true` for registration means the operator
// signs up with email+password first, then enrolls a passkey from
// settings (or a post-signup prompt). Sign-in then offers
// "Sign in with passkey" as a one-tap biometric option.
//
// rpID = the hostname WebAuthn binds the credential to. Pulled
// from PUBLIC_WEB_URL (env), NOT from resolveBaseUrl(request) —
// resolveBaseUrl reflects the request that happened to construct
// this BA instance, and createAuth() is cached for 60s, so a
// request from one host could freeze the rpID for the next host's
// request. PUBLIC_WEB_URL is stable per-deployment.
//
// origin defaults to the request Origin header — handled by the
// plugin itself when we pass `null`. That keeps WebAuthn-side
// origin verification accurate for Vite dev (SPA on a different
// port than the API) and prod.
passkey({
rpID: (() => {
const publicWebUrl = process.env.PUBLIC_WEB_URL?.trim();
if (publicWebUrl) {
try {
const host = new URL(publicWebUrl).hostname;
if (host) return host;
} catch {
/* fallthrough to default */
}
}
return "localhost";
})(),
Comment on lines +537 to +548
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fail fast in production when PUBLIC_WEB_URL is missing/invalid.

Falling back to "localhost" in production can silently misconfigure RP ID and break passkey ceremonies at runtime.

Safer rpID resolution
 rpID: (() => {
 	const publicWebUrl = process.env.PUBLIC_WEB_URL?.trim();
-	if (publicWebUrl) {
-		try {
-			const host = new URL(publicWebUrl).hostname;
-			if (host) return host;
-		} catch {
-			/* fallthrough to default */
-		}
-	}
-	return "localhost";
+	if (!publicWebUrl) {
+		if (runtimeNodeEnv === "production") {
+			throw new Error("PUBLIC_WEB_URL is required for passkey auth in production");
+		}
+		return "localhost";
+	}
+	try {
+		const host = new URL(publicWebUrl).hostname;
+		if (host) return host;
+	} catch {
+		if (runtimeNodeEnv === "production") {
+			throw new Error("PUBLIC_WEB_URL must be a valid absolute URL for passkey auth");
+		}
+	}
+	return "localhost";
 })(),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server/src/auth/index.tsx` around lines 537 - 548, The rpID
resolution IIFE currently falls back to "localhost" when PUBLIC_WEB_URL is
missing/invalid; change it to fail fast in production by detecting production
mode (e.g., process.env.NODE_ENV === "production" or an existing isProduction
flag) and throw a clear error (or call process.exit(1)) if PUBLIC_WEB_URL is
unset or yields an invalid URL/hostname, otherwise keep the existing parsing
behavior for non-production; update the rpID assignment code (the rpID: (() => {
... })() block) to perform this check and surface a descriptive error mentioning
PUBLIC_WEB_URL and rpID resolution.

rpName: "Lobu",
origin: null,
}),
],

databaseHooks: {
Expand Down
Loading