feat(auth): passkey-only 2FA + session/CSRF/secret hardening#10
Merged
Conversation
The Build session image workflow targeted runner/docker/session/Dockerfile, removed in the pi migration. Pi now runs as a child process in the server container, so the standalone session image is no longer built. Also rewrites the README architecture, features, and project structure to match the post-pi layout (no runner service, no S3, no plan mode, no skills directory).
- server/Dockerfile: drop COPY skills/ and skills-lock.json (skills/ was
removed in pi-migration; the build was broken on main)
- skills-lock.json: delete the orphan lock file
- server/.ocd-deploy.json:
- add PI_PROJECTS_ROOT=/app/data/projects so Pi workspaces and chat
JSONLs sit on the persistent volume (otherwise /var/zero is ephemeral
and every restart wipes projects + history)
- bump volume size 10 -> 20 GB to make room for project files
- drop invalid webhook.paths array (schema only allows a single path)
and stale skills/ entry; webhook now redeploys on any push to main
- 100+ models -> multi-model (models.json has 10 curated entries) - drop SOUL.md/MEMORY.md (only HEARTBEAT.md is wired up) - image gen: drop FLUX-specific naming and 'customizable aspect ratios' (handler takes only a prompt, aspect ratio is hardcoded) - apps: describe what 'zero apps create' actually does (port allocation + gateway HTTP proxy) instead of generic 'app deployment' - 'with any model' tagline -> drop, since model set is configured
Replace dual-track 2FA (TOTP authenticator + backup codes + passkeys) with passkeys as the sole second factor, and harden the auth surface end-to-end. - Remove TOTP + backup-code routes, DB columns, types, and all authenticator/QR/recovery UI from login, account, invite, setup - Move JWT from localStorage to HttpOnly+Secure+SameSite cookie; add per-user token_version (bumped on logout + password reset) so every existing session can be killed server-side - Add CSRF double-submit middleware on state-changing routes - Refuse to boot without JWT_SECRET / CREDENTIALS_KEY (>=32 chars) - Refuse to boot in production without CORS_ORIGIN (no `*` fallback) - Gate /api/setup/complete behind X-Setup-Token in production and wrap the existence-check + insert in a transaction - Passkey ceremonies use userVerification: required and are keyed by random ceremony id (no cross-flow / parallel-ceremony collisions) - Generic password-reset-init response shape (no enumeration) - Constant-time login (bcrypt vs dummy hash on unknown user) - Trust X-Forwarded-For only when TRUST_PROXY=1 - Temp tokens carry a jti and are single-use - Bcrypt cost bumped 10 -> 12 on setup, reset, invite-accept
# Conflicts: # README.md
1 task
Anton-Horn
added a commit
that referenced
this pull request
May 11, 2026
* chore: refresh bun.lock to remove otpauth + qrcode Lockfile was left stale after #10 removed the otpauth and qrcode deps from package.json. CI runs --frozen-lockfile and rejected the mismatch. * chore: drop dead RegisterPage Unrouted page still referenced removed registerApi() + old store .login(); only kept around through web tsconfig globbing.
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
Replaces dual-track 2FA (TOTP authenticator app + bcrypt-hashed backup codes + WebAuthn passkeys) with passkeys as the sole second factor, and ships the highest-impact security fixes against the current auth surface in one pass.
The user-facing model is now: password + passkey, nothing else. The Security tab in account settings has a single section.
What's gone
server/routes/totp.ts,server/db/queries/totp.ts,web/src/api/totp.ts,web/src/lib/qr-decode.tsusers.totp_secret,users.totp_enabled,totp_backup_codestable (migration block drops them from existing DBs)LoginPage,AccountPage,InvitePage,SetupPage,HelpPageotpauth+qrcodedeps2faand2fa-reenrollOut of scope by explicit user decision:
CredentialsManager's optionaltotpSecretfield on stored credentials stays — it's for the agent to log into external services on the user's behalf, not for authenticating into zero-agent.Security fixes shipped alongside
JWT_SECRETandCREDENTIALS_KEY: refuse to boot if unset or under 32 chars (no derived fallback fromDB_PATH)CORS_ORIGIN: required in production, no*fallbackHttpOnly; Secure; SameSite=Strictcookie; bearer kept only as a CLI/WS fallbackusers.token_versioncolumn, embedded astvclaim, verified on every request; bumped on logout and on password reset → instant kill of every existing session for a userPOST /api/auth/logoutendpointcsrfcookie +X-CSRF-Tokenheader) middleware on state-changing routes; unauthenticated ceremonies and the pi CLI proxy are exemptuserVerification: requiredeverywhere and are keyed by a random ceremony id (no cross-flow / parallel-ceremony collisions). Added/api/auth/passkey/enroll-options+/enroll-verifyfor in-flow enrollment when an account requires a passkey but doesn't have one yet{ ok: true }shape — no account enumerationX-Forwarded-Foronly whenTRUST_PROXY=1/api/setup/completegated byX-Setup-Tokenheader in production; check + insert wrapped indb.transactionjti; reuse is rejectedNew env required in production
JWT_SECRET(≥32 chars)CREDENTIALS_KEY(≥32 chars)CORS_ORIGIN(exact origin, e.g.https://app.example.com)SETUP_TOKEN(≥16 chars, only required until setup is completed)TRUST_PROXY=1if behind a reverse proxyTest plan
JWT_SECRET/CREDENTIALS_KEY(verified locally)CORS_ORIGIN(verified locally)totp_secret/totp_enabled/totp_backup_codesheals on next bootX-Setup-Tokenrequired in prod; SetupPage runs passkey enrollment after admin creationuserVerification: requiredtoken_versioncurl -X POST /api/mefrom outside the SPA withoutX-CSRF-Token→ 403bun run build(web) green;tsc --noEmitgreen forserver/**andweb/**;vitest run— 22 pass, the 2 failures (search.test.ts) are pre-existing and requireBRAVE_SEARCH_API_KEY🤖 Generated with Claude Code