diff --git a/docs/plans/personal-mode-auth.md b/docs/plans/personal-mode-auth.md deleted file mode 100644 index fe9981a4b..000000000 --- a/docs/plans/personal-mode-auth.md +++ /dev/null @@ -1,267 +0,0 @@ -# Personal-mode auth for the Mac menu bar app - -> **Note:** The Mac app + Chrome extension live in `lobu-ai/owletto` as of -> #TBD. Mac-side changes specified in this plan happen in that repo; -> server/gateway/auth changes (the majority of this plan) still happen here -> in lobu. Cross-repo paths below reference `lobu-ai/owletto: apps/mac/…`. - -## Goal - -When the macOS menu bar app starts the embedded Lobu server on this Mac, replace the OAuth device flow with a frictionless local-only auth model. The Mac user becomes the Lobu user automatically. No sign-in screen, no device code, no email entry. - -## Non-goals - -- Multi-user / org / team auth on the same install. If a user wants that, they run Lobu directly (`lobu run`, Docker, K8s) and the Mac app's "Remote" field points at it. -- Replacing Better Auth / OAuth in the gateway. This work *adds* a local-mode path that integrates with existing auth, not replaces it. -- Migrating a personal-mode install into a cloud account. Treated as a one-way choice for v1; export/import is a follow-up. - -## What already exists in `main` (audit results) - -This section anchors the design to actual code so we don't propose duplicates. - -| Concern | Existing code | Reuse / extend? | -|---|---|---| -| Loopback validation | `packages/server/src/start-local.ts:82` checks `127.0.0.1` / `localhost` / `::1` | **Extend** — add bind+verify semantics, refuse `0.0.0.0`/external. | -| Localhost URL validation | `packages/server/src/gateway/auth/oauth/utils.ts` | **Reuse** — same helper. | -| User + org auto-provision | `ensurePersonalOrganization()` (in `personal-org-provisioning.ts`) — idempotent, slug collision + reserved names handled, anchors via `personal_org_for_user_id` metadata | **Reuse** — call from local-mode bootstrap with a synthesized user record instead of a Better-Auth-issued one. | -| Keychain | `lobu-ai/owletto: apps/mac/Lobu/KeychainTokenStore.swift` — service `ai.lobu.mac`, `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly` | **Extend** — add a separate Keychain account key for the personal-mode secret, same service. | -| CORS / cookie credentials | `packages/server/src/index.ts:266` — `isAllowedCorsOrigin()` checks localhost variants, `credentials: true` | **Extend** — fold CSRF middleware (Sec-Fetch-Site / Host / Content-Type / custom-header) into the same chain. | -| Data dir | `LOBU_DATA_DIR` env (defaults `~/.lobu/data`), set by `LocalLobuRunner.swift:74` | **Extend** — switch menu bar to per-user subdir (`~/.lobu-menubar//data`). | -| Bind port | `LocalLobuRunner` hardcodes `:8787` | **Replace** — per-user free port discovery. | -| Better Auth sessions | Used by the web SPA | **Integrate** — local-mode bootstrap mints a Better Auth session for the local user, so the SPA needs zero changes. | - -Genuinely missing (the surface this doc specifies): -- Secret bootstrap channel (stdin handshake) and `LOBU_PERSONAL_MODE=1` env. -- `personal.marker` data-dir file + startup refusal logic. -- `personalAuth` middleware (Bearer + `X-Lobu-Client`). -- Bootstrap-token endpoints (`/__local/bootstrap`, `/__local/exchange`). -- Tunnel detection. -- Per-user free port allocation. -- Reset / desync recovery path. - ---- - -## Auth model - -Two distinct authenticated paths, each fit for purpose. - -### 1. Menu bar app ↔ embedded server - -**Secret provisioning at server startup — stdin handshake.** No env var (leaks via same-user `/proc` or `ps auxe` on Linux; even on macOS it's a softer surface than stdin). No argv (visible in `ps`). No disk artifact (extra surface to manage). - -Sequence: - -1. Menu bar app generates a 32-byte random secret (base64). -2. Menu bar app writes Keychain entry: service `ai.lobu.mac`, account `personal-auth-token`, accessibility `kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly`. -3. Menu bar app spawns `lobu run` with stdin attached, `LOBU_PERSONAL_MODE=1` + `LOBU_DATA_DIR=` + `--bind 127.0.0.1` + `--port 0` (request a free port). -4. Menu bar app writes a single JSON line to stdin and closes the write half: - ```json - { - "secret": "", - "identity": { - "handle": "", - "display_name": "", - "hostname": "" - } - } - ``` -5. Server reads the line on boot, validates personal mode, stores the secret in memory, and: - - Reads/writes `personal.marker` in the data dir (see §Boundaries). - - Calls `ensurePersonalOrganization()` with the supplied identity (synthesized email = `@.local`, `auth_provider="local"`). - - Binds the listener and verifies `server.address()` is loopback (see §Boundaries). - - Prints `LOBU_LISTEN_PORT=` to stdout as the first line, terminated with `\n`. -6. Menu bar app reads that line to learn the actual port and persists it to `~/.lobu-menubar//port`. - -Why stdin: it's not visible to `ps`, doesn't land on disk, and the parent-child pipe is exclusive — no other process can read it. Same-user processes with `ptrace`/`task_for_pid` can still attach and read the running server's memory, but at that point the whole user account is compromised; same threat boundary as the Keychain itself. - -**Steady-state auth (after first start):** - -- Every menu-bar HTTP call sets: - - `Authorization: Bearer ` - - `X-Lobu-Client: menubar` -- Server validates both. Custom header makes browser-driven CSRF preflight-only; CORS denies the preflight (see §CSRF), so a malicious site can't forge the header. -- If the server returns 401, the menu bar **deletes the Keychain entry, stops the runner, and starts fresh**. This is also the manual reset path. - -### 2. Browser ↔ embedded server ("Open Lobu" flow) - -The web SPA already uses Better Auth sessions. We integrate, not parallel. - -Sequence: - -1. User clicks "Open Lobu" in the menu bar. -2. Menu bar app calls `POST /__local/bootstrap` (authenticated with the Keychain secret). Server generates a one-time bootstrap token (32 random bytes, base64), stores **the hash** (`sha256(token)`) in an in-memory map keyed by hash, with TTL 10 seconds and a single-use flag. Server returns the plaintext token to the menu bar. -3. Menu bar app opens `http://127.0.0.1:/?bootstrap=` in the user's browser. -4. The SPA's bootstrap handler (small new entry in `packages/owletto/src/`): - 1. Reads `?bootstrap=` from `window.location`. - 2. `history.replaceState()` immediately to strip the query (no history leakage). - 3. `POST /__local/exchange` with `{ token }` (no auth header — the token IS the auth). - 4. Server hashes the supplied token, atomically deletes the entry from the in-memory map (the `Map.delete()` return value is the only authoritative "is this the first call" signal — prevents double-submit races), verifies TTL, then calls Better Auth to mint a session for the local user. Sets the standard Better Auth session cookie (HttpOnly, SameSite=Lax, no `Secure` flag since localhost is plain HTTP). -5. Web SPA continues with the Better Auth session as normal. Existing API routes recognize it without modification. - -**Session lifetime / refresh:** - -- The browser session uses Better Auth's default TTL (today: 30 days). The bootstrap is fast and friction-free, so we don't try to engineer "silent refresh." When the session expires the SPA shows its existing expired-session redirect; the user clicks "Open Lobu" in the menu bar and gets a fresh session in one click. -- The 10-second TTL on the bootstrap token is for the URL handoff only — short enough that a leaked token (browser sync, screen-share) expires before it's useful. - ---- - -## Personal-mode boundaries (structural) - -The "you can't accidentally expose this" requirement only holds if enforced by the server, not by a config the user can flip. - -### Bind enforcement - -- Server reads `LOBU_PERSONAL_MODE=1` at boot. If set: - - Refuse to start unless `bind` is `127.0.0.1` or `::1`. - - After `server.listen()`, call `server.address()` and assert the result is loopback. Crash with a clear message if not. - - Reject `0.0.0.0`, `::`, external interface IPs, and `localhost` resolutions that aren't loopback (some custom `/etc/hosts` setups). -- Extends the existing check in `start-local.ts:82` with a post-listen assertion. - -### `personal.marker` - -- On first server start in personal mode, write `/personal.marker` containing `{ "created_at": "", "owner_handle": "" }`. -- On subsequent starts: - - If `LOBU_PERSONAL_MODE=1` and marker present → load identity from marker, proceed. - - If `LOBU_PERSONAL_MODE=1` and marker absent → first run (create marker). - - If `LOBU_PERSONAL_MODE=1` unset and marker present → refuse to start. Forces deliberate migration away from personal mode (delete the marker manually is the documented one-way step). -- Marker file is the structural boundary. Mode is not a runtime flag the same data dir can flip. - -### CSRF + CORS lockdown - -A custom header (`X-Lobu-Client: menubar`) only helps if CORS doesn't grant it to arbitrary origins. The chain (extending the existing middleware in `index.ts:266`): - -- **CORS:** allowed origins are exactly `http://127.0.0.1:` and `http://localhost:`. No wildcards. No reflection of `Origin`. `Access-Control-Allow-Headers` does **not** include `X-Lobu-Client` or `Authorization` for cross-origin preflights — a foreign tab's preflight fails before the actual request runs. -- **Origin / Sec-Fetch-Site:** all mutating routes (POST/PUT/PATCH/DELETE) require either: - - `Sec-Fetch-Site: same-origin` or `Sec-Fetch-Site: none` (no-CORS, navigation), OR - - `Origin` present and in the allowed list above. -- **Missing-`Origin` behavior:** native clients (the Mac app) often omit `Origin`. For mutating routes, require either (a) `Origin` present-and-allowed, OR (b) `X-Lobu-Client: menubar` + valid Bearer (the menu bar path). Pure missing-Origin without the Lobu client header → 403. -- **`Host` allowlist:** request `Host` header must be `127.0.0.1[:port]` or `localhost[:port]` — reject DNS rebinding attacks. -- **`Content-Type`:** mutations must be `application/json` (no `text/plain`, no `application/x-www-form-urlencoded`, no `multipart/form-data`). Defeats CSRF "simple request" posts. -- **`OPTIONS`:** preflight allowed only from same-origin; deny all cross-origin preflights silently. - -### Tunnel detection (advisory, not a boundary) - -Pi was right: process scanning is bypassable. We do it anyway as a hint, but the security guarantee is loopback bind + the CSRF stack above. - -On startup, log a warning (not an error, doesn't refuse to start) if any of: - -- `tailscaled` is running AND `tailscale status --json` shows the local node has Funnel enabled. -- `ngrok`, `cloudflared`, or `frpc` processes are running. - -Surface the warning to the menu bar app via the startup-stdout protocol (one extra `LOBU_WARNING=` line). The menu bar shows it as a notification with a "Learn more" link. Reframed from the previous draft: not a refusal, an advisory. - ---- - -## User / org auto-provision (integration with existing infra) - -Reuses `ensurePersonalOrganization()`. - -On stdin handshake: - -1. Server checks if a user with `auth_provider = "local"` and `handle = ` already exists. -2. If not, inserts a user row inside a transaction with a `UNIQUE (auth_provider, handle)` constraint to prevent races. Fields: - - `id`: UUID. - - `handle`: ``. - - `display_name`: `` (fall back to handle if empty). - - `email`: `@.local` (placeholder, never sent anywhere). - - `auth_provider`: `"local"`. - - `created_at`: now. -3. Calls `ensurePersonalOrganization(userId, { displayName, handle })`. This is idempotent today, so retries are safe. -4. The default agent is created by `ensurePersonalOrganization` already (existing behavior — verify in implementation). - -If the user already exists (data dir from a previous run), step 2 is skipped and step 3 is a no-op. Bootstrap is idempotent. - -Avatar / real email: not in v1. Documented in §Open questions. - ---- - -## Threat model - -| Threat | Mitigation | -|---|---| -| Network attacker on LAN reaches `:` | Loopback bind enforced at server (refuses non-loopback in personal mode; post-listen assertion verifies). | -| Browser tab on the same Mac CSRFs the API | Strict CORS (no foreign-origin preflight passes for `X-Lobu-Client`/`Authorization`) + Origin + Sec-Fetch-Site + Host + Content-Type checks. | -| Other process on the same Mac (same user) reads Keychain or attaches to server memory | Out of scope — same-user adversary already owns the data. | -| Other macOS user on a shared Mac hits localhost | Per-user data dir + per-user port = each user runs their own server on their own port. A sibling user can hit `127.0.0.1:` but doesn't have the Keychain secret, so all sensitive endpoints 401. CSRF stack still applies. | -| Tunnel (Tailscale Funnel / ngrok / cloudflared) exposes localhost to internet | Loopback bind doesn't prevent tunnels by itself, but: (a) advisory startup warning, (b) `Host` allowlist rejects requests with non-localhost `Host` headers (most tunnels rewrite this), (c) menu bar UI flags the warning. Not a hard guarantee; documented. | -| User configures `HOST=0.0.0.0` thinking it'll just work | Server in personal mode refuses to start with non-loopback bind. Marker enforces single-mode-per-data-dir. | -| Bootstrap token leaks (browser sync, screen-share, history) | One-time use enforced by atomic `Map.delete()`. 10-second TTL. Stripped from URL via `history.replaceState()` immediately. Stored as hash, not plaintext, so server memory dump doesn't reveal usable tokens. | -| Bootstrap token replay between server restarts | Token store is in-memory only. Server restart invalidates all tokens. | -| Long-lived Keychain secret leaks | Same boundary as user's filesystem. Mitigation: revoke + regenerate is one click ("Reset Lobu" — see §Reset). | -| Cross-tab credential confusion (a tab from Cloud thinks it's local) | Cookie scoped to `Path=/`, Better Auth issues distinct session per origin. Web SPA loaded from `app.lobu.ai` can't read a localhost cookie. | - ---- - -## Reset / desync recovery - -The Keychain and server-side `personal.marker` can desync (Keychain wiped, data dir copied to a new machine, etc). Recovery must be explicit and visible. - -**Detection:** -- Menu bar app gets 401 on a steady-state call → assumes desync. - -**Action:** -- Menu bar app shows: "Local Lobu is out of sync with this Mac. Reset and start fresh?" with one button. -- On confirm: delete Keychain entry, stop the runner, delete `~/.lobu-menubar//data/` (including `personal.marker`), restart the runner. Triggers the full first-launch handshake. -- Connector configs (folder bookmarks, vault selections) live in UserDefaults and survive the reset. Server-side history (events, runs) is wiped — same as a fresh install. - -This is also what "Sign out" maps to in personal mode (the menu has no "Sign out" today when signed in; we add a "Reset Lobu…" item to the footer that does the above). - ---- - -## Implementation surface area - -### Server (`packages/server/src/`) - -- New: stdin handshake reader at boot (before HTTP listener starts). Parses identity + secret. -- New: `personalAuth` middleware that validates Bearer + `X-Lobu-Client`. -- New: CSRF middleware (Origin / Sec-Fetch-Site / Host / Content-Type), folded into the existing CORS chain in `index.ts:266`. -- New: bind-enforcement assertion after `server.listen()`, extending `start-local.ts:82`. -- New: `personal.marker` write/read + mode-conflict refusal. -- New routes: - - `POST /__local/bootstrap` — auth: personal-Bearer. Mints a bootstrap token. Returns `{ token }`. - - `POST /__local/exchange` — no auth header (token IS auth). Atomically burns token, mints Better Auth session, sets cookie. - - `GET /__local/identity` — auth: personal-Bearer. Returns current local user info. (Optional, for the menu bar to show "Signed in as Burak Emre Kabakcı".) -- Tunnel detection: best-effort startup check, emits `LOBU_WARNING=…` to stdout. -- Print `LOBU_LISTEN_PORT=` to stdout as the first protocol line after handshake. - -### Mac app (`lobu-ai/owletto: apps/mac/Lobu/`) - -- `KeychainTokenStore.swift`: add a `personal-auth-token` account on the existing `ai.lobu.mac` service. -- `LocalLobuRunner.swift`: - - Pick a per-user free port (try a fixed list `8787, 8788, ..., 8800`, then fall back to `:0` if needed and read it back from `LOBU_LISTEN_PORT=` stdout). - - Pass `LOBU_PERSONAL_MODE=1`, `LOBU_DATA_DIR=~/.lobu-menubar//data`, `--bind 127.0.0.1`, `--port `. - - Attach stdin pipe, write the handshake JSON on spawn, close write half. - - Read first stdout line, parse port + warning. - - Persist port to `~/.lobu-menubar//port` for crash recovery. -- New: `PersonalAuthClient.swift` — handles secret generation, Keychain round-trip, `Authorization` + `X-Lobu-Client` header injection on every API call, bootstrap-token mint + browser-open helper for "Open Lobu", 401 → reset trigger. -- `MenuBarContent.swift`: - - When URL is loopback, skip the OAuth UI entirely. Button label becomes "Start" (the runner does the work; no separate sign-in step). Footer adds a "Reset Lobu…" inline-confirm action. - -### Web (`packages/owletto/src/`) - -- Small bootstrap handler in the SPA entry: reads `?bootstrap=`, `history.replaceState`, `POST /__local/exchange`, then proceeds. Treats the resulting Better Auth session like any other. -- Existing routes unchanged. - -### CLI (`packages/cli/src/`) - -- `lobu run`: accept `--bind`, `--port` (with `0` = OS-assigned), and read stdin for the handshake when `LOBU_PERSONAL_MODE=1`. No CLI flag changes for non-personal mode. - -### Tests - -- Server: stdin handshake (valid / malformed / wrong-mode), personalAuth middleware (valid / missing-header / wrong-token), CSRF (cross-origin preflight rejected, missing Origin without Lobu header rejected, wrong Host rejected, wrong content-type rejected), bootstrap (one-time, expiry, atomic burn), bind enforcement (post-listen assertion crashes on 0.0.0.0). -- Mac app: Keychain round-trip, stdin write/read, port discovery, 401 reset flow. - ---- - -## Open questions - -- **Real email detection from Contacts "Me" card.** Out of v1. -- **Avatar from macOS account picture.** Out of v1 — `~/Library/Caches/com.apple.iconservices.store` is gnarly. -- **Export to cloud.** Personal mode is one-way for v1. Future: `lobu export --personal` / `lobu import` ships events + connections + agents to a target org. Marker file is the signal. -- **Multi-server on the same Mac (e.g., dev + personal at the same time).** Per-user data dir handles separate users; for the same user running both, the menu bar uses `~/.lobu-menubar//`, and a developer running `lobu run` directly uses whatever they configure (default `~/.lobu`). They won't collide unless the dev points at the menu bar's data dir — documented in `lobu run --help`. -- **What happens if the user kills `lobu run` from outside (e.g. `pkill`).** Menu bar's runner watcher should detect, restart with the same handshake (Keychain secret is reused). Verify in implementation. -- **"Open Lobu" when the server is stopped.** Currently the footer opens `state.baseURL` blindly. New behavior: if not running, start it first, then open. Specified in the Mac app section. - -## Out of scope (do later) - -- Avatar, real email, export/import, multi-server dev/personal coexistence beyond per-user data dir. diff --git a/packages/owletto b/packages/owletto index 9e89d33ef..41e4b3268 160000 --- a/packages/owletto +++ b/packages/owletto @@ -1 +1 @@ -Subproject commit 9e89d33ef60a3b6c39050e26475252301d4522fe +Subproject commit 41e4b32684aa98ab28c4772051a9589300bfaee7 diff --git a/packages/server/src/auth/routes.ts b/packages/server/src/auth/routes.ts index 8574ab7cf..100af5c00 100644 --- a/packages/server/src/auth/routes.ts +++ b/packages/server/src/auth/routes.ts @@ -7,10 +7,13 @@ * - POST /api/:orgSlug/tokens - Create org-scoped personal access tokens */ +import { createHmac } from 'node:crypto'; import { type Context, Hono } from 'hono'; import { createDbClientFromEnv } from '../db/client'; import type { Env } from '../index'; import { errorMessage } from '../utils/errors'; +import { resolveBaseUrl } from './base-url'; +import { createAuth } from './index'; import { mcpAuth, requireAuth } from './middleware'; import { OAuthClientsStore } from './oauth/clients'; import { PersonalAccessTokenService } from './tokens'; @@ -198,4 +201,73 @@ credentialRoutes.post('/:orgSlug/tokens', mcpAuth, async (c) => { } }); +/** + * Exchange a Personal Access Token for a Better Auth session cookie. + * + * Lets a holder of a valid PAT (CLI users, the macOS menu-bar app, deep links + * from the operator's terminal) hop into the web UI without typing a password. + * The endpoint validates the PAT, mints a fresh session row tied to the same + * user, signs the session token with BETTER_AUTH_SECRET (matching what + * Better Auth would set), and 302-redirects to `next` (default `/`). + * + * `next` is restricted to relative paths to prevent open-redirect abuse. The + * Referrer-Policy header keeps the PAT out of the next page's Referer. + */ +credentialRoutes.get('/exchange-token', async (c) => { + // Don't leak the PAT into the next request's Referer header. + c.header('Referrer-Policy', 'no-referrer'); + + const token = c.req.query('token')?.trim(); + if (!token) { + return c.json({ error: 'missing_token', error_description: 'token query param is required' }, 400); + } + + const sql = createDbClientFromEnv(c.env); + const patService = new PersonalAccessTokenService(sql); + const authInfo = await patService.verify(token); + if (!authInfo) { + return c.json({ error: 'invalid_token', error_description: 'token is invalid, expired, or revoked' }, 401); + } + + const secret = c.env.BETTER_AUTH_SECRET; + if (!secret) { + return c.json( + { error: 'server_misconfigured', error_description: 'BETTER_AUTH_SECRET not set' }, + 500 + ); + } + + const auth = await createAuth(c.env, c.req.raw); + const ctx = await auth.$context; + const session = await ctx.internalAdapter.createSession(authInfo.userId); + if (!session?.token) { + return c.json({ error: 'session_create_failed', error_description: 'failed to mint session' }, 500); + } + + // Match Better Auth's cookie shape: `.`, + // URL-encoded. Cookie name picks up the __Secure- prefix when the request + // arrived over HTTPS so it stays compatible with the prod baseURL rule. + const sig = createHmac('sha256', secret).update(session.token).digest('base64'); + const cookieValue = encodeURIComponent(`${session.token}.${sig}`); + // Match Better Auth's cookie-prefix rule: __Secure- iff the public baseURL + // is https. Resolve via the same helper used during sign-in so the prefix + // matches even when TLS is terminated by a reverse proxy (Tailscale Funnel, + // nginx, cloudflared) and the loopback bind itself speaks plain HTTP. + const isHttps = resolveBaseUrl({ request: c.req.raw }).startsWith('https://'); + const cookieName = isHttps ? '__Secure-better-auth.session_token' : 'better-auth.session_token'; + const cookieParts = [ + `${cookieName}=${cookieValue}`, + 'Path=/', + 'HttpOnly', + 'SameSite=Lax', + `Max-Age=${60 * 60 * 24 * 7}`, + ]; + if (isHttps) cookieParts.push('Secure'); + c.header('Set-Cookie', cookieParts.join('; ')); + + const rawNext = c.req.query('next') ?? '/'; + const safeNext = rawNext.startsWith('/') && !rawNext.startsWith('//') ? rawNext : '/'; + return c.redirect(safeNext, 302); +}); + export { credentialRoutes }; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 12f584f18..60e2e88bb 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -65,7 +65,6 @@ import { entityLinkMatchSql } from './utils/content-search'; import { isValidFrameAncestor } from './utils/csp'; import { errorMessage } from './utils/errors'; import logger from './utils/logger'; -import { isLoopbackHost } from './utils/loopback'; import { generateOpenAPISpec } from './utils/openapi-generator'; import { extractSubdomainOrg, @@ -278,71 +277,6 @@ app.use( }) ); -// CSRF defense for no-auth mode. With LOBU_NO_AUTH=1 the server attributes -// every request to the local user without checking any token — so any -// website you visit in your browser could `fetch('http://localhost:8787/...')` -// from a tab and side-effect the API. Block that by requiring same-origin -// markers on every mutating method. CORS preflights already deny foreign -// origins from carrying `Authorization`, but they don't prevent simple- -// request POSTs that side-effect without reading the response, so we need -// these checks on top. -app.use('/*', async (c, next) => { - if (process.env.LOBU_NO_AUTH !== '1') return next(); - const method = c.req.method.toUpperCase(); - if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS') { - return next(); - } - const origin = c.req.header('origin'); - const sfs = c.req.header('sec-fetch-site'); - const hostHeader = (c.req.header('host') ?? '').toLowerCase(); - const ct = (c.req.header('content-type') ?? '').toLowerCase(); - const lobuClient = c.req.header('x-lobu-client'); - - // Host header must be one of the loopback aliases. Defeats DNS rebinding. - // Uses the shared isLoopbackHost helper so the alias set stays in lock-step - // with the bind-time enforcement in start-local.ts / server.ts. Strip the - // optional `:` suffix and IPv6 brackets before checking. - const hostBare = hostHeader.replace(/^\[(.+)\](?::\d+)?$/, '$1').replace(/:\d+$/, ''); - if (!isLoopbackHost(hostBare)) { - return c.json({ error: 'forbidden', error_description: 'No-auth mode: bad Host header' }, 403); - } - - // Origin / Sec-Fetch-Site: at least one must say "same-origin or none". - // Native clients (Mac app) typically omit Origin but set X-Lobu-Client. - // - // The Origin match is exact-against-this-server, not any-loopback-shape. - // Previously we accepted any `http(s)://(127.x.x.x|localhost|[::1])(:port)?` - // which let a malicious tab loaded from e.g. `http://localhost:9999` send - // CSRF mutations to our `:8787`. Now we derive the canonical origin from - // the validated Host header above and require an exact string match. - const expectedOrigin = `http://${hostHeader}`; - const sameOrigin = - sfs === 'same-origin' || - sfs === 'none' || - origin === expectedOrigin; - const trustedNative = lobuClient !== undefined && lobuClient.length > 0; - if (!sameOrigin && !trustedNative) { - return c.json( - { error: 'forbidden', error_description: 'No-auth mode: cross-origin mutation rejected' }, - 403 - ); - } - - // Content-Type for state-changing requests must be application/json. - // Defeats CSRF "simple request" form posts that browsers allow without - // preflight (application/x-www-form-urlencoded, text/plain, multipart). - // Empty Content-Type is also rejected — a CSRF attacker omitting it - // would otherwise slip through. - if (!ct.includes('application/json')) { - return c.json( - { error: 'forbidden', error_description: 'No-auth mode: mutations must be application/json' }, - 415 - ); - } - - return next(); -}); - // Add Pino logger middleware app.use( '*', diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 2e58c0e1b..6e28e460f 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -44,7 +44,6 @@ import { assertExternalDepsResolvable } from '../../connector-worker/src/runtime import { isSentryReported, markSentryReported } from './sentry'; import { getEnvFromProcess } from './utils/env'; import logger from './utils/logger'; -import { isLoopbackHost } from './utils/loopback'; import { assertSchemaUpToDate } from './utils/schema-version-check'; import { initWorkspaceProvider } from './workspace'; @@ -280,32 +279,7 @@ async function main() { // Start HTTP server logger.info({ port }, 'Starting server'); - // No-auth mode is a single-user, loopback-only personal mode (see - // docs/plans/personal-mode-auth.md). It must NEVER be active on a - // production deployment behind any kind of public bind. Refuse to start - // if the operator accidentally sets it. Both pre-listen (host arg) and - // post-listen (server.address()) checks catch DNS / hostname surprises. - const noAuth = process.env.LOBU_NO_AUTH === '1'; - if (noAuth && !isLoopbackHost(host)) { - logger.error( - { host }, - 'LOBU_NO_AUTH=1 requires loopback bind (127.0.0.0/8 or ::1). Refusing to start.' - ); - process.exit(1); - } - httpServer.listen(port, host, () => { - if (noAuth) { - const addr = httpServer.address(); - if (typeof addr === 'object' && addr && !isLoopbackHost(addr.address)) { - logger.error( - { address: addr.address }, - 'LOBU_NO_AUTH=1 server bound to a non-loopback address after listen() — refusing to serve.' - ); - process.exit(1); - } - logger.info('No-auth mode active (LOBU_NO_AUTH=1) — every request attributed to local user'); - } logger.info({ host, port }, `Server running at http://${host}:${port}`); // Crash loud if the runtime image is missing any connector external dep, // instead of letting every feed silently fail with "Missing npm diff --git a/packages/server/src/start-local.ts b/packages/server/src/start-local.ts index e07107be5..4233c33d2 100644 --- a/packages/server/src/start-local.ts +++ b/packages/server/src/start-local.ts @@ -44,7 +44,6 @@ import { listMigrationFiles, loadMigrationUpSection } from './db/migration-loade import type { Env } from './index'; import { getEnvFromProcess } from './utils/env'; import logger from './utils/logger'; -import { isLoopbackHost } from './utils/loopback'; const DATA_DIR = process.env.LOBU_DATA_DIR || join(homedir(), '.lobu', 'data'); const PORT = parseInt(process.env.PORT || '8787', 10); @@ -73,8 +72,6 @@ function isTruthyEnv(name: string): boolean { return /^(1|true|yes|on)$/i.test(process.env[name]?.trim() ?? ''); } -// `isLoopbackHost` lives in `./utils/loopback` so `server.ts` can share it. - async function main() { mkdirSync(DATA_DIR, { recursive: true }); @@ -194,8 +191,8 @@ async function main() { // ─── Bootstrap PAT ─────────────────────────────────────────── // Runs BEFORE listen so that: // (a) the bootstrap user / org / PAT row are guaranteed to exist before - // the first request can land — no-auth mode would otherwise 503 - // during the gap between listen() and ensureBootstrapPat()'s await. + // the first request can land — first-boot UI calls would otherwise + // race the bootstrap and 401 against a not-yet-provisioned user. // (b) a stale bootstrap-pat.txt (file exists, DB rows missing because // LOBU_DATA_DIR was wiped) gets re-minted now, while we still own // the boot sequence. @@ -221,32 +218,9 @@ async function main() { // ─── Listen ────────────────────────────────────────────────── - // No-auth mode is loopback-only by design. Refuse to listen on anything - // other than the IPv4 loopback /8 / ::1 (matches isLoopbackHost) — both - // via the configured HOST (early fail) and via a post-listen - // `server.address()` check that catches DNS / hostname surprises. - const noAuth = process.env.LOBU_NO_AUTH === '1'; - if (noAuth && !isLoopbackHost(HOST)) { - logger.error( - { host: HOST }, - 'LOBU_NO_AUTH=1 requires loopback bind (127.0.0.0/8 or ::1). Refusing to start.' - ); - process.exit(1); - } httpServer.listen(PORT, HOST, () => { - if (noAuth) { - const addr = httpServer.address(); - if (typeof addr === 'object' && addr && !isLoopbackHost(addr.address)) { - logger.error( - { address: addr.address }, - 'LOBU_NO_AUTH=1 server bound to a non-loopback address after listen() — refusing to serve.' - ); - process.exit(1); - } - } logger.info(`Lobu running at http://${HOST}:${PORT}`); logger.info(`Data: ${DATA_DIR}`); - if (noAuth) logger.info('No-auth mode active (LOBU_NO_AUTH=1) — every request attributed to local user'); }); } @@ -394,10 +368,9 @@ async function ensureBootstrapPat(dbUrl: string): Promise { try { // Stale-state detection: previously this early-returned whenever the PAT // file existed, but if LOBU_DATA_DIR was wiped between runs the row was - // gone and no-auth mode would 503 forever. Now we check all three rows - // (user + org + member) — if ANY is missing the bootstrap re-runs to - // restore consistency. Partial state could otherwise wedge getNoAuthUser - // forever. + // gone and clients holding the PAT file would see a missing-user 500. Now + // we check all three rows (user + org + member) — if ANY is missing the + // bootstrap re-runs to restore consistency. const stateRows = await sql< [{ user_exists: boolean; org_exists: boolean; member_exists: boolean }] >` @@ -437,20 +410,6 @@ async function ensureBootstrapPat(dbUrl: string): Promise { SELECT count(*)::int AS count FROM "user" WHERE id <> ${BOOTSTRAP_USER_ID} `; if ((otherUserCountRows[0]?.count ?? 0) > 0) { - // If LOBU_NO_AUTH=1 is set, the auth bypass needs the bootstrap user - // to attribute every request to. Skipping bootstrap here while no-auth - // is on leaves resolveAuth() returning 503 forever — visible only at - // request time, when the user has no clear path to recover. Fail loud - // at startup instead so the operator sees the mismatch immediately. - if (process.env.LOBU_NO_AUTH === '1') { - logger.error( - { otherUserCount: otherUserCountRows[0]?.count }, - 'LOBU_NO_AUTH=1 requires the bootstrap user to exist, but this deployment ' + - 'already has non-bootstrap users — no-auth mode would 503 every request. ' + - 'Either unset LOBU_NO_AUTH for this deployment or use a clean LOBU_DATA_DIR.' - ); - process.exit(1); - } logger.debug( { userCount: otherUserCountRows[0]?.count }, 'Skipping bootstrap PAT — deployment already has non-bootstrap users' diff --git a/packages/server/src/utils/loopback.ts b/packages/server/src/utils/loopback.ts deleted file mode 100644 index e2d489f43..000000000 --- a/packages/server/src/utils/loopback.ts +++ /dev/null @@ -1,15 +0,0 @@ -/// True iff `host` is a loopback address — accepts everything in `127.0.0.0/8`, -/// `::1`, `[::1]`, IPv4-mapped IPv6 loopback (`::ffff:127.x.y.z`), and the -/// literal `localhost`. Case-insensitive on the host portion. -/// -/// Used by both `start-local.ts` and `server.ts` to enforce that -/// `LOBU_NO_AUTH=1` only ever serves on a loopback bind — refusing to start -/// when a production deployment accidentally has the env set. -export function isLoopbackHost(host: string | undefined | null): boolean { - if (!host) return false; - const h = host.toLowerCase().replace(/^\[|\]$/g, ''); - if (h === 'localhost' || h === '::1') return true; - if (/^127\.(?:\d{1,3}\.){2}\d{1,3}$/.test(h)) return true; - if (/^::ffff:127\.(?:\d{1,3}\.){2}\d{1,3}$/.test(h)) return true; - return false; -} diff --git a/packages/server/src/workspace/multi-tenant.ts b/packages/server/src/workspace/multi-tenant.ts index de95b36b7..ffb699dfa 100644 --- a/packages/server/src/workspace/multi-tenant.ts +++ b/packages/server/src/workspace/multi-tenant.ts @@ -28,14 +28,8 @@ import { // Re-export the test-only cache clearer so existing imports // (`from '../workspace/multi-tenant'`) keep working; the cache instances // themselves live in `./multi-tenant-caches` to keep test cleanup off this -// file's heavy import graph. We wrap rather than re-export so we can also -// reset the no-auth user cache that lives in THIS file (see `getNoAuthUser` -// below) — tests that swap bootstrap rows or LOBU_NO_AUTH between cases -// would otherwise see stale identity. -export function clearMultiTenantCachesForTests(): void { - clearMultiTenantCachesForTestsShared(); - noAuthUserCache = null; -} +// file's heavy import graph. +export const clearMultiTenantCachesForTests = clearMultiTenantCachesForTestsShared; /** * Path namespaces that don't carry an org context. Authenticated requests to @@ -112,62 +106,6 @@ export async function getCachedOrgBySlug( /// Bootstrap identity constants — must match the constants in /// `packages/server/src/start-local.ts` (BOOTSTRAP_USER_ID + BOOTSTRAP_ORG_ID). -/// Duplicated here intentionally to avoid an import cycle with the local-only -/// entrypoint. If you change either side, change both. -const NO_AUTH_USER_ID = 'bootstrap-user'; -const NO_AUTH_ORG_ID = 'org-bootstrap-dev'; - -/// Cache the no-auth user/org lookup. The values can't change while the -/// server is alive: bootstrap-user/org are seeded once by `ensureBootstrapPat` -/// and live forever. Clearing the cache requires a server restart. -let noAuthUserCache: NoAuthUser | null = null; - -interface NoAuthUser { - userId: string; - organizationId: string; - user: { id: string; email: string; name: string; username: string }; -} - -/// Resolve the single local user attributed to every request in no-auth -/// mode. Pulls the bootstrap-user row + the bootstrap-org membership by -/// **id pair**, not "first admin LIMIT 1" — if the bootstrap user ever -/// has multiple memberships (e.g. someone manually added them to another -/// org for testing) we still want the personal org, deterministically. -/// Returns `null` only when the row truly doesn't exist (server boot race -/// between HTTP listen and ensureBootstrapPat's await — `start-local.ts` -/// now runs the bootstrap BEFORE listen() to close that race). -async function getNoAuthUser( - sql: ReturnType -): Promise { - if (noAuthUserCache) return noAuthUserCache; - const rows = await simpleQuery(sql` - SELECT - u.id AS user_id, - u.email AS email, - u.name AS name, - u.username AS username - FROM "user" u - JOIN "member" m ON m."userId" = u.id - WHERE u.id = ${NO_AUTH_USER_ID} - AND m."organizationId" = ${NO_AUTH_ORG_ID} - AND m.role IN ('owner', 'admin') - LIMIT 1 - `); - if (rows.length === 0) return null; - const row = rows[0]; - noAuthUserCache = { - userId: row.user_id as string, - organizationId: NO_AUTH_ORG_ID, - user: { - id: row.user_id as string, - email: (row.email as string) ?? '', - name: (row.name as string) ?? '', - username: (row.username as string) ?? '', - }, - }; - return noAuthUserCache; -} - /** * Direct org lookup by id. Uncached — ids are a fallback path for the sandbox's * `.org(slugOrId)` accessor, so the TTL cache hit rate would be near-zero. @@ -320,55 +258,6 @@ export class MultiTenantProvider implements WorkspaceProvider { return next(); } - // ─── No-auth (personal/local) mode ────────────────────────────────────── - // When LOBU_NO_AUTH=1 (set by the macOS menu bar's LocalLobuRunner), the - // server is bound to 127.0.0.1 only and treats every request as the - // single local user that `ensureBootstrapPat()` created on first boot. - // No bearer, no cookie, no OAuth — the entire point is to remove that - // ceremony for personal use on this Mac. - if (process.env.LOBU_NO_AUTH === '1') { - const noAuthUser = await getNoAuthUser(sql); - if (!noAuthUser) { - return c.json( - { - error: 'server_not_ready', - error_description: - 'No-auth mode is enabled but the local user has not been provisioned yet. Retry shortly.', - }, - 503 - ); - } - // If a slug was supplied in the URL it must match the local user's org. - // No-auth mode is single-org by definition — refusing other slugs makes - // misconfiguration loud instead of silently attributing cross-org data. - if (requestedOrgId && requestedOrgId !== noAuthUser.organizationId) { - return c.json( - { - error: 'forbidden', - error_description: - 'No-auth mode is single-org; the URL slug must match the local user organization.', - }, - 403 - ); - } - await setContextAndContinue({ - mcpAuthInfo: { - userId: noAuthUser.userId, - organizationId: noAuthUser.organizationId, - clientId: 'lobu-no-auth', - scopes: ['mcp:read', 'mcp:write', 'mcp:admin'], - expiresAt: Math.floor(Date.now() / 1000) + 60 * 60, - tokenType: 'pat', - }, - mcpIsAuthenticated: true, - organizationId: noAuthUser.organizationId, - memberRole: 'owner', - user: noAuthUser.user, - authSource: 'session', - }); - return undefined; - } - // 1) Embedded worker direct-auth for the in-process lobu-memory MCP. // The gateway MCP proxy sets this header after validating/issuing the worker // token. Treat it as an internal admin-scoped MCP session for the URL org so