feat(auth): cookie pivot — drop bootstrap-pat.txt, add /api/auth/local-init#830
Conversation
…l-init
[WIP — handing off mid-stream, server + CLI compile, Mac client lives in
owletto branch feat/mac-cookie-pivot. Server alone is independently
mergeable; bearer plugin + new endpoint do not break existing PAT flows.]
Replaces the long-lived bootstrap-PAT-in-a-file model with on-demand
Better Auth session minting. PostgreSQL becomes the only source of
truth — the `session` table on issuance, the `user` row on seeding — and
no plaintext credential lands on disk under the server's data dir.
Server:
- `start-local.ts`: rename `ensureBootstrapPat` → `ensureBootstrapUser`.
Keep the user/org/member seed; drop the personal_access_tokens INSERT
and the bootstrap-pat.txt write. Drop the PAT-printing stdout banner.
- `auth/index.tsx`: enable Better Auth's `bearer()` plugin so a session
token works either as a Cookie or as `Authorization: Bearer`.
- `auth/routes.ts`:
• new `POST /api/auth/local-init` — mints a session for the
bootstrap user. Refuses any `forwarded-*` / `x-real-ip` header
(loopback-origin trust model: a proxy fronting the bind sets
these, the endpoint refuses, the bootstrap session never escapes
the host). Refuses when non-bootstrap users exist (prod safety).
• `GET /api/exchange-token` now accepts session tokens too via
`internalAdapter.findSession` — the menu bar's deep-link URL
works for both PATs (legacy) and bootstrap sessions.
- `workspace/multi-tenant.ts`: when a `Bearer <token>` isn't a PAT and
isn't an OAuth access token, fall through to the session-cookie
check (the bearer plugin translates Authorization → session lookup).
CLI:
- `internal/credentials.ts`: when `getToken()` finds no saved creds and
the context URL is loopback, transparently POSTs `/api/auth/local-init`
and persists the returned session token. `lobu chat -c local` works
without a prior `lobu login`.
- `commands/dev.ts`: after `lobu run` boots, `announceLocalSignIn`
fetches a session token, registers a `local` CLI context pointing at
the gateway, persists creds, and prints a `?lobu_token=<session>`
deep-link URL the user can click straight into the web UI.
Removed:
- `packages/server/src/__tests__/unit/bootstrap-pat-file-mode.test.ts`
— file no longer exists.
Companion Mac client refactor (drops `LocalLobuRunner.readBootstrapPat`,
rewrites `AppState.adoptLocalCredentials` to POST `/local-init`) lives in
the owletto submodule on branch `feat/mac-cookie-pivot`. Submodule
pointer isn't bumped yet — the next agent should land the Mac PR, then
bump and merge this parent PR.
Out of scope here (E2E test queued but not run):
- Verify POST /api/auth/local-init returns Set-Cookie + JSON body.
- Verify Bearer + session_token roundtrip via /api/auth/get-session.
- Verify ?lobu_token=<session> exchange path.
- Verify personal_access_tokens has no row for bootstrap-user.
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (12)
📝 WalkthroughWalkthroughRefactors local server authentication from PAT-file bootstrap to loopback-peer-validated user seeding with Bearer token support. Establishes peer-address capture in server middleware, adds ChangesLoopback Bootstrap Authentication
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related issues
Possibly related PRs
Suggested labels
Poem
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Warning There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure. 🔧 ESLint
ESLint skipped: no ESLint configuration detected in root package.json. To enable, add Comment |
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…h-all doesn't shadow it
Renamed POST /api/auth/local-init → POST /api/local-init across server,
CLI, and Mac client. Better Auth registers `app.on(['GET','POST'],
'/api/auth/*', ...)` before credentialRoutes mounts, so the old path
returned a plain 404. Caught during E2E.
E2E now green:
- 200 happy path with session_token + user + organization JSON.
- Cookie via Set-Cookie roundtrips through /api/auth/get-session.
- bearer plugin: same session_token via Authorization: Bearer resolves
the same session row.
- /api/exchange-token?token=<session> 302s with fresh Set-Cookie.
- X-Forwarded-For: 8.8.8.8 → 403 proxied_request_refused.
- personal_access_tokens has zero rows for bootstrap-user (the whole
point — session table is the only credential primitive).
- CLI getToken('local') with empty credentials.json auto-inits and
resolves to bootstrap-user.
Drive-by lint fixes (pre-existing, blocked the precommit hook):
- packages/landing/src/components/FeatureGraphics.tsx: drop unused
`messagingChannels` import.
- packages/cli/src/commands/_lib/export/__tests__/export-cmd.test.ts:
replace `bundle.watchers?.[0]!` non-null-after-optional-chain with
a guard.
11d4a68 to
1699a78
Compare
A malicious page visited by the user while \`lobu run\` is up could fire
a no-preflight simple POST against \`http://localhost:8787/api/local-init\`
and land a bootstrap session cookie in the victim's browser jar. The
attacker can't read the response cross-origin, but the side effect
(session row creation, signed-in state in the user's browser) is still
unwanted.
Require \`X-Lobu-Client\` on this endpoint. Custom headers force a CORS
preflight which we don't allow from foreign origins → the malicious POST
gets blocked before the handler runs. All three legitimate callers
already set the header:
- CLI (\`credentials.ts\` + \`dev.ts\`): \`X-Lobu-Client: cli\` / \`lobu-run\`.
- Menu bar (Swift): \`X-Lobu-Client: menubar\`.
- Chrome extension: sets it from extension context, which has
host_permissions and isn't subject to the same CORS rules.
088e836 to
cba563f
Compare
Bare Better Auth sessions carry no scopes, so the menu bar's watcher poll (\`/api/workers/poll\`) and the Chrome extension's worker poll both 403 with "missing device_worker:run scope" against the session token. A regression vs. the pre-PR PAT model where the bootstrap PAT had owner scopes by construction. Widen the response: in addition to the Better Auth session (used for the Set-Cookie and the SPA's \`?lobu_token=\` deep link), also mint a PAT scoped to the bootstrap user + bootstrap org with \`device_worker:run mcp:read mcp:write mcp:admin\` and return it as \`device_token\`. Native clients use the PAT as their bearer credential; the session is for browser cookie handoff only. PostgreSQL still holds the truth — PAT hash in personal_access_tokens, session row in session. Nothing on disk at the server's data dir. CLI + Mac client updated to prefer device_token (with session_token fallback for older servers). E2E confirms /api/workers/poll now returns 200 with the PAT as Bearer.
Pi flagged: start-local.ts defaulted HOST=0.0.0.0, so the embedded runner was reachable on the LAN. Combined with /api/local-init's forwarded-* + X-Lobu-Client gates only catching *proxied* requests (not direct LAN peers), anyone on the same Wi-Fi as a `lobu run` could POST /local-init directly and walk away with a device_token PAT scoped to device_worker:run + mcp:admin. Two-layer fix: 1. **Loopback bind by default** (`start-local.ts`): HOST now defaults to 127.0.0.1. Operators who explicitly want LAN reachability must set HOST=0.0.0.0 themselves — opt-in instead of opt-out. This is the primary trust boundary; the OS won't even route LAN packets to a loopback bind. 2. **Per-request loopback-peer check** (`/api/local-init`, defense-in-depth): The env-swap middleware in server.ts / start-local.ts now stashes `c.env.incoming.socket.remoteAddress` into `c.var.peerRemoteAddress` before c.env is overwritten with the app config. /local-init refuses any non-loopback peer with a 403 `non_loopback_peer`. Catches the case where an operator set HOST=0.0.0.0 and forgot. `isLoopbackAddress` handles 127.0.0.0/8, ::1, and IPv4-mapped IPv6 (::ffff:127.0.0.x — what Node sometimes hands us with a dual-stack listener).
Draft — server + CLI for the cookie-pivot. Companion: owletto#159 (Chrome ext + Mac client).
Summary
Replaces the bootstrap-PAT-in-a-file model with on-demand credential minting via
POST /api/local-init. PostgreSQL becomes the only source of truth (PAT hash inpersonal_access_tokens, session row insession) — no plaintext credential under the server's data dir.Server
start-local.ts:ensureBootstrapPat→ensureBootstrapUser. Keeps the user/org/member seed; drops the PAT INSERT and thebootstrap-pat.txtwrite. Drops the PAT-printing stdout banner. Drops__tests__/unit/bootstrap-pat-file-mode.test.ts.auth/index.tsx: enable Better Auth'sbearer()plugin so a session token works either as a Cookie or asAuthorization: Bearer.auth/routes.ts:POST /api/local-init— mints BOTH a Better Auth session AND a worker-scoped PAT for the bootstrap user. Refuses anyforwarded-*/forwarded/x-real-ipheader (loopback-origin trust: any proxy fronting the bind sets these). Refuses withoutX-Lobu-Clientheader (CSRF gate — forces a CORS preflight that foreign origins fail). Refuses when non-bootstrap users exist (prod safety). Returns{session_token, device_token, device_token_scope, cookie_name, user, organization}+Set-Cookie./api/exchange-tokennow accepts session tokens (in addition to PATs) viainternalAdapter.findSession. The menu bar's?lobu_token=<session>deep-link URL works alongside PAT URLs.workspace/multi-tenant.ts: when aBearer <token>isn't a PAT (owl_pat_prefix) and isn't an OAuth access token, fall through to the session-cookie check (the bearer plugin then resolves it).CLI
internal/credentials.ts: whengetToken()finds no saved creds and the context URL is loopback, transparently POSTs/api/local-initand persists the returneddevice_token(worker-scoped PAT, falls back tosession_tokenfor older servers).lobu chat -c localworks zero-config.commands/dev.ts: afterlobu runboots,announceLocalSignIn()registers alocalCLI context, persists creds, and prints a?lobu_token=<session>deep-link URL the user can click straight into the web UI.What's removed
packages/server/src/__tests__/unit/bootstrap-pat-file-mode.test.ts— file no longer exists.Why two tokens?
A bare Better Auth session carries no scopes;
/api/workers/*middleware checksmcpAuthInfo.scopesfordevice_worker:runormcp:admin. The menu bar's watcher poll and the Chrome extension's worker poll both need that scope, so/local-initalso mints a worker-scoped PAT. The session is retained for browser cookie handoff (the SPA's?lobu_token=hook expects a session-shaped token).Test plan ✅
make typecheckclean.POST /api/local-initwithX-Lobu-Client: e2eagainst fresh PGlite returns 200 withsession_token,device_token, identity payload,Set-Cookie. E2E verified.POST /api/local-initwithoutX-Lobu-Client→ 403missing_client_header. E2E verified.POST /api/local-initwithX-Forwarded-For: 8.8.8.8→ 403proxied_request_refused. E2E verified.Authorization: Bearer <session_token>resolves via Better Auth bearer plugin on/api/auth/get-session. E2E verified.Authorization: Bearer <device_token>(PAT) passes/api/workers/pollscope gate → 200{next_poll_seconds:10}. E2E verified.?lobu_token=<session>against/api/exchange-token→ 302 with freshSet-Cookie. E2E verified.personal_access_tokenshas one row for bootstrap-user (the worker-scoped one we just minted),sessiontable has ≥1 row,bootstrap-userrow exists. E2E verified.getToken('local')with empty~/.config/lobu/credentials.jsonauto-inits and resolves tobootstrap-uservia Bearer. E2E verified.Out of repo
Mac + Chrome client wiring: owletto#159.
Summary by CodeRabbit
New Features
Improvements