-
Notifications
You must be signed in to change notification settings - Fork 20
feat: passkey (WebAuthn) auth + auth-config flags for local-mode routing #905
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
560d133
f8f41d6
dcabd5d
07a0c5e
d32589f
d3c7466
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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"); | ||
|
|
||
| -- migrate:down | ||
|
|
||
| DROP TABLE IF EXISTS "passkey"; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: The expected data type for the Citations:
Change The 🤖 Prompt for AI Agents |
||
| "createdAt" timestamp with time zone DEFAULT now() NOT NULL, | ||
| aaguid text | ||
| ); | ||
|
coderabbitai[bot] marked this conversation as resolved.
Comment on lines
+1552
to
+1564
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Yes, the Citations:
Add UNIQUE constraint on credentialID column. The Proposed fixAdd 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 |
||
|
|
||
| -- | ||
| -- 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'); | ||
| 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"; | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fail fast in production when Falling back to 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 |
||
| rpName: "Lobu", | ||
| origin: null, | ||
| }), | ||
| ], | ||
|
|
||
| databaseHooks: { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Enforce uniqueness for
credentialID.credentialIDis currently only indexed, not constrained unique. Duplicate credential IDs can make passkey lookup ambiguous during authentication.Suggested migration adjustment
📝 Committable suggestion
🤖 Prompt for AI Agents