feat(auth): native WebAuthn passkeys for panel login#4544
Open
iyobo wants to merge 14 commits into
Open
Conversation
Co-authored-by: Cursor <cursoragent@cursor.com>
Resolve WebAuthn RP/origin from env or server settings, document self-hosted setup, add audit logs for passkey lifecycle, and polish login/profile UX with tests. Co-authored-by: Cursor <cursoragent@cursor.com>
Remove login autofill race, improve Abort/NotAllowed handling, close register dialog before WebAuthn, align trustedOrigins/baseURL, and add passkey-hardening plan. Co-authored-by: Cursor <cursoragent@cursor.com>
…l UI Enforce TOTP on passkey sign-in, centralize WebAuthn ceremony handling, restore safe conditional autofill, and align origin/trustedOrigins config. Co-authored-by: Cursor <cursoragent@cursor.com>
…n-in Keep passive email-field mediation outside the explicit-action mutex so the sign-in button can open the device prompt instead of showing a false busy error. Co-authored-by: Cursor <cursoragent@cursor.com>
Start email-field autofill only after focus, preempt it before manual sign-in, and swallow abort/not-allowed errors so the login page never shows a Next.js overlay. Co-authored-by: Cursor <cursoragent@cursor.com>
Fix a React batching race where pendingAdd was still false when onOpenChange fired, so Add passkey closed the modal without ever opening the WebAuthn prompt. Co-authored-by: Cursor <cursoragent@cursor.com>
Separate server PASSKEY_NOT_FOUND from browser NotAllowedError so deleted passkeys show removal guidance instead of first-login copy. Co-authored-by: Cursor <cursoragent@cursor.com>
Merge upstream/canary and renumber the passkey table migration to 0172 after upstream landed 0170 (forward auth) and 0171 (SSO FK). Co-authored-by: Cursor <cursoragent@cursor.com>
Author
|
@Siumauricio let's give the people what they want 👍🏽 The magic begins at
|
Document Settings → Server as the default passkey origin path, soften error copy, and add fleet deploy notes without requiring env URL pins. Co-authored-by: Cursor <cursoragent@cursor.com>
Use esbuild for @dokploy/server in the Dockerfile, add @better-auth/utils for Next bundling, and point server package exports at dist so production builds resolve reliably. Co-authored-by: Cursor <cursoragent@cursor.com>
Drop fleet-specific planning docs from the upstream-facing branch. Co-authored-by: Cursor <cursoragent@cursor.com>
Keep personal planning notes and root .env.production local; remove links to untracked plans from env examples and passkey copy. Co-authored-by: Cursor <cursoragent@cursor.com>
Treat a user-verifying passkey as complete MFA so passkey sign-in completes the session directly instead of forcing a TOTP step; TOTP stays enforced for the email/password flow. Remove the global baseURL override (unnecessary for routing and passkeys; matches canary) along with the now-dead trust-device/HMAC code and the @better-auth/utils dependency. Co-authored-by: Cursor <cursoragent@cursor.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Summary
This PR adds WebAuthn passkeys (Touch ID, Face ID, Windows Hello, hardware security keys) to Dokploy panel login and account settings. It uses Better Auth's
@better-auth/passkeyplugin pinned to 1.5.4, matching the existingbetter-auth/@better-auth/*versions so no 1.6 upgrade is pulled in.Passkeys give users passwordless, phishing-resistant sign-in that runs alongside the existing email/password, SSO, and TOTP 2FA factors rather than replacing them. This implements the request in #1296.
What changed (high level)
auth.ts): thepasskey()plugin is wired in and the Relying Party (rpID/origin) is resolved at startup from a single priority chain. Better Auth'sbaseURLis left unset (matchingcanary); the handler infers it per request, and passkey origin validation uses the requestOriginheader plustrustedOrigins. No global auth behavior changes.auth-cli.ts): a minimal, static-RP Better Auth config exists solely so@better-auth/cli generatecan regenerateauth-schema2.ts— it is never used at runtime.passkeytable is added via Drizzle migration0172_huge_next_avengers.sql, which also runs automatically on container start.pages/index.tsx): a "Sign in with passkey" button and conditional autofill are added. A user-verifying passkey is treated as complete MFA, so passkey sign-in goes straight to the dashboard without a TOTP step (see Behavioral notes).manage-passkeys.tsx): users can list, register, rename, and delete passkeys, with a separate "Use security key instead" path for cross-platform authenticators.passkeyaudit resource type.tscto an esbuild bundle (see Build & infra changes).Architecture / key files
packages/server/src/lib/passkey-rp.tsrpID/rpName/originvia a priority chain; pure, unit-tested helperspackages/server/src/lib/auth.tspasskey()plugin,trustedOrigins, and before/after hooks (email verify, audit)packages/server/src/lib/auth-cli.ts@better-auth/cli generatepackages/server/src/db/schema/passkey.tspasskeytable, relation, and indexesapps/dokploy/drizzle/0172_huge_next_avengers.sqlpasskeytableapps/dokploy/pages/index.tsxapps/dokploy/utils/hooks/use-passkey-conditional-ui.tsapps/dokploy/lib/passkey-ceremony.tsapps/dokploy/components/dashboard/settings/profile/manage-passkeys.tsxpackages/server/src/db/schema/audit-log.ts,components/proprietary/audit-logs/data-table.tsxpasskeyresource type and its renderingRP (Relying Party) resolution — single source of truth
resolvePasskeyRpConfig()chooses the WebAuthnrpID/originonce whenauth.tsinitializes, because the plugin only accepts static values (this is why a restart is required after URL changes). It resolves in priority order:IS_CLOUD) uses a hardcodedhttps://app.dokploy.com.BETTER_AUTH_URLorNEXT_PUBLIC_APP_URLif set (trimmed, trailing slash stripped). This is optional.NODE_ENV=development, no env) falls back tohttp://localhost:${PORT}.serverIp:PORTif Host is unset.rpIDis the hostname (localhoststayslocalhost). No staticoriginis passed to the plugin, so the verify step validates against the requestOriginheader instead, and the resolved origin is appended totrustedOrigins.Behavioral notes
No global auth changes.
baseURLis deliberately left unset (as oncanary), so email-verification and password-reset links keep using their request-inferred URL. Verified locally: with the bundle rebuilt,get-sessionand the passkey option endpoints return200withoutbaseURL, so it is not needed for routing or for passkeys.Passkey + TOTP 2FA. A passkey that performed user verification (Touch ID / Face ID / Windows Hello / PIN) is already phishing-resistant multi-factor auth — possession of the authenticator plus a biometric/PIN — so it satisfies 2FA on its own. Passkey sign-in therefore completes the session directly and does not prompt for a TOTP code, matching how Google, Microsoft, GitHub, and Apple treat passkey sign-in. TOTP remains required for the email/password flow.
Hooks — scoped to passkey paths. The hooks below are guarded by
ctx.pathand only fire on the new/passkey/*endpoints, so existing email-login, SSO, and reset flows are unchanged.IS_CLOUDandrequireEmailVerificationare set, an unverified user who signs in via passkey is rejected (session deleted) and a fresh verification email is sent — matching the existing email-login enforcement.afterhook recordsverify-registration(create) anddelete-passkey(delete), and abeforehook snapshots the passkey's name prior to deletion because the row no longer exists by the time the after hook runs.Database schema
The migration adds one table,
passkey(packages/server/src/db/schema/passkey.ts). Each row is a single registered credential bound to a user.idtextnametextpublic_keytextuser_idtextuser(id),ON DELETE CASCADE; indexed (passkey_userId_idx)credential_idtextpasskey_credentialID_idx)counterintegerdevice_typetextbacked_upbooleantransportstextusb,nfc,ble,internal)created_attimestampaaguidtextBecause
user_idcascades, deleting a user automatically removes all of their passkeys.Dependencies
@better-auth/passkey@1.5.4, pinned to the existing 1.5.x line.Added to
packages/serverandapps/dokploy, andpnpm-lock.yamlis updated.User flows
You can walk through each flow end-to-end on a self-hosted instance, provided HTTPS is enabled and Settings → Server (Host + HTTPS) matches the URL you open in the browser.
1. First-time passkey setup (requires an existing account)
/dashboard/settings/profile).Expected: step 8 succeeds. A user who clicks passkey sign-in before completing steps 1–6 sees "No passkey is set up for this site yet…" rather than an error overlay.
2. Passkey sign-in with 2FA enabled
3. Conditional passkey autofill (supported browsers)
Autofill is gated behind
isConditionalMediationAvailable, deferred until the email field is focused, and disabled while Enforce SSO is on or the 2FA screen is showing. Conditional sessions use a generation counter so StrictMode double-mounts and manual-button preemption never leak ceremonies.4. Delete a passkey
Expected: the error reads "This passkey is no longer registered with Dokploy. Remove it from your device (Passwords or iCloud Keychain)…", which is deliberately distinct from the first-time "No passkey is set up for this site yet" message.
5. Security key registration
6. SSO enforce mode (Enterprise)
Expected: only SSO sign-in is shown; the email/password and passkey buttons are hidden. Registration still requires an authenticated session via Profile, so operators must temporarily disable enforce mode if users need to register passkeys through password login first.
Self-hosted operators
Passkeys need no environment variables in most installs. The passkey Relying Party (
rpIDandorigin) is derived from your public Dokploy URL using the existinggetDokployUrl()logic, so configuring that URL correctly is the only requirement.Standard path — Settings → Server. Set Host to the exact hostname users type in their browser (e.g.
dokploy.example.com) and enable HTTPS for any non-localhost host (WebAuthn requires a secure context; onlylocalhostmay use plain HTTP). That's enough for registration and sign-in to work.Optional env override.
BETTER_AUTH_URLorNEXT_PUBLIC_APP_URLpin the URL in container env instead of the UI — use only for local dev (http://localhost:3000), GitOps/manifest-driven URLs, or fixing a stale Host. When set, they override Settings → Server, so a wrong value silently breaks passkeys.Cloud (
IS_CLOUD) is automatic: the RP is fixed tohttps://app.dokploy.com.Restart after URL changes. The RP is resolved once at startup, so any change to Host/HTTPS or an env override requires a Dokploy restart. Passkeys are origin-bound — if the public URL changes, existing passkeys must be re-registered.
Build & infra changes
Dockerfile. The
@dokploy/serverbuild step changes frompnpm --filter=@dokploy/server build(tsc) torun switch:prod && exec tsx esbuild.config.ts. The reason is thattschits a stack overflow on this tree, whereas esbuild bundles the serverdistreliably. This is the only non-auth change that affects the runtime, so reviewers and CI should confirm the produced image still works..gitignore. Ignores.env.productionand a localplans/notes folder.Env examples.
.env.exampleand.env.production.examplenow document the optionalBETTER_AUTH_URLoverride, left commented out by default.Automated tests
apps/dokploy/__test__/lib/passkey-ceremony.test.tscovers concurrent-ceremony rejection (the busy message), sequential reuse, detection of abort /NotAllowedError/SecurityError, and the fullgetPasskeyErrorMessagemapping (stale vs first-time vs cancelled vs origin-mismatch, per flow).apps/dokploy/__test__/lib/passkey-rp.test.tscovers RP resolution across cloud, env-override precedence, dev localhost, Settings → Server, theserverIpfallback, and the error fallback, plusoriginMatchesRpConfigandpasskeyRpFromOrigin.Test plan
0172_huge_next_avengers.sql; it also runs automatically on container start).pnpm test).127.0.0.1vslocalhostshows a clear error when the optional dev override expectslocalhost.Security notes
Originheader.resourceType: passkey.passkeyrows cascade-delete with the owning user.Related