diff --git a/bun.lock b/bun.lock index 515aacf2f..72a5560d0 100644 --- a/bun.lock +++ b/bun.lock @@ -17,7 +17,7 @@ }, "packages/agent-worker": { "name": "@lobu/worker", - "version": "7.0.0", + "version": "7.2.0", "bin": { "lobu-worker": "./dist/index.js", }, @@ -44,7 +44,7 @@ }, "packages/cli": { "name": "@lobu/cli", - "version": "7.0.0", + "version": "7.2.0", "bin": { "lobu": "bin/lobu.js", }, @@ -52,6 +52,7 @@ "@anthropic-ai/sdk": "^0.90.0", "@aws-sdk/client-bedrock": "^3.1028.0", "@aws-sdk/client-secrets-manager": "^3.1028.0", + "@better-auth/passkey": "^1.6.9", "@chat-adapter/discord": "4.26.0", "@chat-adapter/gchat": "4.26.0", "@chat-adapter/slack": "4.26.0", @@ -126,7 +127,7 @@ }, "packages/connector-sdk": { "name": "@lobu/connector-sdk", - "version": "7.0.0", + "version": "7.2.0", "dependencies": { "@lobu/core": "workspace:*", "@sinclair/typebox": "^0.34.41", @@ -147,7 +148,7 @@ }, "packages/connector-worker": { "name": "@lobu/connector-worker", - "version": "7.0.0", + "version": "7.2.0", "bin": { "connector-worker": "./dist/bin.js", }, @@ -170,7 +171,7 @@ }, "packages/connectors": { "name": "@lobu/connectors", - "version": "7.0.0", + "version": "7.2.0", "dependencies": { "@lobu/connector-sdk": "workspace:*", "baileys": "7.0.0-rc.9", @@ -184,7 +185,7 @@ }, "packages/core": { "name": "@lobu/core", - "version": "7.0.0", + "version": "7.2.0", "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-trace-otlp-grpc": "^0.57.0", @@ -203,7 +204,7 @@ }, "packages/embeddings": { "name": "@lobu/embeddings", - "version": "7.0.0", + "version": "7.2.0", "dependencies": { "@hono/node-server": "^1.13.7", "@xenova/transformers": "^2.17.2", @@ -234,7 +235,7 @@ }, "packages/openclaw-plugin": { "name": "@lobu/openclaw-plugin", - "version": "7.0.0", + "version": "7.2.0", "devDependencies": { "@types/node": "^20.10.0", "postgres": "^3.4.7", @@ -247,6 +248,7 @@ "version": "1.6.0", "dependencies": { "@assistant-ui/react": "^0.14.0", + "@better-auth/passkey": "^1.6.9", "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.2", "@codemirror/lang-json": "^6.0.2", @@ -320,6 +322,7 @@ "@anthropic-ai/sdk": "^0.90.0", "@aws-sdk/client-bedrock": "^3.1028.0", "@aws-sdk/client-secrets-manager": "^3.1028.0", + "@better-auth/passkey": "^1.6.9", "@chat-adapter/discord": "4.26.0", "@chat-adapter/gchat": "4.26.0", "@chat-adapter/slack": "4.26.0", diff --git a/db/migrations/20260519000000_passkey_table.sql b/db/migrations/20260519000000_passkey_table.sql new file mode 100644 index 000000000..67fa2a253 --- /dev/null +++ b/db/migrations/20260519000000_passkey_table.sql @@ -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"); + +-- migrate:down + +DROP TABLE IF EXISTS "passkey"; diff --git a/db/schema.sql b/db/schema.sql index 9e89e40a8..a8d8a3ccf 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -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, + "createdAt" timestamp with time zone DEFAULT now() NOT NULL, + aaguid text +); + -- -- Name: pending_interactions; Type: TABLE; Schema: public; Owner: - -- @@ -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: - -- @@ -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: - -- @@ -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: - -- @@ -5343,5 +5387,6 @@ INSERT INTO public.schema_migrations (version) VALUES ('20260518050000'), ('20260518060000'), ('20260518070000'), + ('20260519000000'), ('20260519020000'), ('20260519020001'); diff --git a/packages/cli/package.json b/packages/cli/package.json index f8f241b6b..2317abbae 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/owletto b/packages/owletto index 0275737be..d5ae2a44d 160000 --- a/packages/owletto +++ b/packages/owletto @@ -1 +1 @@ -Subproject commit 0275737be28ba12011d3e23b5e35acbbbb928c04 +Subproject commit d5ae2a44d5e7f19ec6c04f646f2c75fcbfa76949 diff --git a/packages/server/package.json b/packages/server/package.json index 7978c37ef..03251c6a6 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -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", diff --git a/packages/server/src/auth/config.ts b/packages/server/src/auth/config.ts index e86c26f31..81b1d8b49 100644 --- a/packages/server/src/auth/config.ts +++ b/packages/server/src/auth/config.ts @@ -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'; @@ -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 }; } diff --git a/packages/server/src/auth/index.tsx b/packages/server/src/auth/index.tsx index 1752c9204..e0d59341e 100644 --- a/packages/server/src/auth/index.tsx +++ b/packages/server/src/auth/index.tsx @@ -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"; @@ -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"; + })(), + rpName: "Lobu", + origin: null, + }), ], databaseHooks: {