Skip to content

feat(auth): cookie pivot — drop bootstrap-pat.txt, add /api/auth/local-init#830

Merged
buremba merged 6 commits into
mainfrom
feat/auth-cookie-pivot
May 17, 2026
Merged

feat(auth): cookie pivot — drop bootstrap-pat.txt, add /api/auth/local-init#830
buremba merged 6 commits into
mainfrom
feat/auth-cookie-pivot

Conversation

@buremba
Copy link
Copy Markdown
Member

@buremba buremba commented May 17, 2026

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 in personal_access_tokens, session row in session) — no plaintext credential under the server's data dir.

Server

  • start-local.ts: ensureBootstrapPatensureBootstrapUser. Keeps the user/org/member seed; drops the PAT INSERT and the bootstrap-pat.txt write. Drops the PAT-printing stdout banner. Drops __tests__/unit/bootstrap-pat-file-mode.test.ts.
  • 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/local-init — mints BOTH a Better Auth session AND a worker-scoped PAT for the bootstrap user. Refuses any forwarded-* / forwarded / x-real-ip header (loopback-origin trust: any proxy fronting the bind sets these). Refuses without X-Lobu-Client header (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-token now accepts session tokens (in addition to PATs) via internalAdapter.findSession. The menu bar's ?lobu_token=<session> deep-link URL works alongside PAT URLs.
  • workspace/multi-tenant.ts: when a Bearer <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: when getToken() finds no saved creds and the context URL is loopback, transparently POSTs /api/local-init and persists the returned device_token (worker-scoped PAT, falls back to session_token for older servers). lobu chat -c local works zero-config.
  • commands/dev.ts: after lobu run boots, announceLocalSignIn() registers a local CLI 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 checks mcpAuthInfo.scopes for device_worker:run or mcp:admin. The menu bar's watcher poll and the Chrome extension's worker poll both need that scope, so /local-init also 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 typecheck clean.
  • POST /api/local-init with X-Lobu-Client: e2e against fresh PGlite returns 200 with session_token, device_token, identity payload, Set-Cookie. E2E verified.
  • POST /api/local-init without X-Lobu-Client → 403 missing_client_header. E2E verified.
  • POST /api/local-init with X-Forwarded-For: 8.8.8.8 → 403 proxied_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/poll scope gate → 200 {next_poll_seconds:10}. E2E verified.
  • ?lobu_token=<session> against /api/exchange-token → 302 with fresh Set-Cookie. E2E verified.
  • DB invariants: personal_access_tokens has one row for bootstrap-user (the worker-scoped one we just minted), session table has ≥1 row, bootstrap-user row exists. E2E verified.
  • CLI getToken('local') with empty ~/.config/lobu/credentials.json auto-inits and resolves to bootstrap-user via Bearer. E2E verified.

Out of repo

Mac + Chrome client wiring: owletto#159.

Summary by CodeRabbit

  • New Features

    • Automatic local sign-in flow during development startup with credential provisioning
    • Bearer token authentication support for CLI and non-browser clients
    • Bootstrap user provisioning with web login credentials for local deployments
  • Improvements

    • Loopback-only networking by default for enhanced security in local development environments

Review Change Stack

…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.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 17, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 29240bb8-2967-43f5-98f9-06addea64350

📥 Commits

Reviewing files that changed from the base of the PR and between 4fddc72 and 9fcf895.

📒 Files selected for processing (12)
  • packages/cli/src/commands/_lib/export/__tests__/export-cmd.test.ts
  • packages/cli/src/commands/dev.ts
  • packages/cli/src/internal/credentials.ts
  • packages/landing/src/components/FeatureGraphics.tsx
  • packages/owletto
  • packages/server/src/__tests__/unit/bootstrap-pat-file-mode.test.ts
  • packages/server/src/auth/index.tsx
  • packages/server/src/auth/middleware.ts
  • packages/server/src/auth/routes.ts
  • packages/server/src/server.ts
  • packages/server/src/start-local.ts
  • packages/server/src/workspace/multi-tenant.ts

📝 Walkthrough

Walkthrough

Refactors 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 /api/local-init endpoint for credential minting, seeds bootstrap user/org/member credentials, and integrates automatic local context registration in the CLI.

Changes

Loopback Bootstrap Authentication

Layer / File(s) Summary
Loopback Peer Trust Infrastructure
packages/server/src/auth/middleware.ts, packages/server/src/server.ts, packages/server/src/start-local.ts
TCP peer addresses are captured in c.var.peerRemoteAddress before request environment is replaced, and default server binding is changed to 127.0.0.1 to maintain loopback-only trust boundary.
Bearer Token Auth Support
packages/server/src/auth/index.tsx
Better Auth's bearer() plugin is imported and configured to accept session tokens via Authorization: Bearer headers, enabling non-cookie clients to authenticate.
Session Cookie Minting Refactor
packages/server/src/auth/routes.ts
New helpers isLoopbackAddress, mintSessionCookieValue, and resolveDeepLinkToken consolidate session/cookie logic; /api/exchange-token refactored to use centralized minting and unified PAT/session token resolution.
Local-Init Bootstrap Endpoint
packages/server/src/auth/routes.ts
New POST /api/local-init endpoint enforces loopback-only access, validates X-Lobu-Client header, checks bootstrap database state, and returns both session token and device PAT for CLI credential registration.
Bootstrap User Seeding Migration
packages/server/src/start-local.ts
Replaces PAT-file bootstrap with ensureBootstrapUser that idempotently seeds user/org/member rows and Better Auth account credentials; removes PAT-related imports and prints bootstrap web login credentials instead of PAT values.
Bearer Token Fallthrough in Multi-Tenant Auth
packages/server/src/workspace/multi-tenant.ts
Bearer token validation now distinguishes invalid PATs (return 401 immediately) from non-PAT bearer tokens (fall through to session-cookie auth path), allowing session tokens delivered via Authorization: Bearer to resolve downstream.
CLI Local Sign-in Integration
packages/cli/src/commands/dev.ts, packages/cli/src/internal/credentials.ts
Dev command spawns announceLocalSignIn after server startup to poll /health, call POST /api/local-init, and register a local CLI context; getToken() now falls back to tryLocalInit() for zero-config bootstrap when no credentials are stored.
Test Updates and Cleanup
packages/cli/src/commands/_lib/export/__tests__/export-cmd.test.ts
Export command test assertions normalized for optional watchers array; bootstrap PAT file permission test removed as PAT bootstrap is replaced by user seeding.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

  • lobu-ai/lobu#827: Both PRs modify packages/server/src/auth/routes.ts around token-to-session handoff; retrieved PR introduces /api/exchange-token PAT flow that the main PR refactors and extends with /api/local-init.
  • lobu-ai/lobu#785: Both PRs modify packages/server/src/start-local.ts bootstrap behavior; retrieved PR changes ensureBootstrapPat under LOBU_NO_AUTH=1 while main PR replaces PAT bootstrap entirely with ensureBootstrapUser.
  • lobu-ai/lobu#779: Main PR's new server/CLI local bootstrap flow (POST /api/local-init with loopback validation and credential minting) builds directly on the retrieved PR's embedded no-auth bootstrap work in start-local.ts and multi-tenant.ts.

Suggested labels

skip-size-check

Poem

🐰 A loopback trust is born today,
Where peers are checked the proper way,
No PATs scrawled on disk to keep,
Just bearer tokens, local and deep,
CLI and server now handshake tight—
Bootstrap auth, secure and right! 🔐

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/auth-cookie-pivot

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

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 17, 2026

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 13.79310% with 100 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
packages/cli/src/commands/dev.ts 3.61% 80 Missing ⚠️
packages/cli/src/internal/credentials.ts 39.39% 20 Missing ⚠️

📢 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.
@buremba buremba force-pushed the feat/auth-cookie-pivot branch from 11d4a68 to 1699a78 Compare May 17, 2026 21:37
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.
@buremba buremba force-pushed the feat/auth-cookie-pivot branch from 088e836 to cba563f Compare May 17, 2026 21:50
buremba added 2 commits May 17, 2026 23:13
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).
@buremba buremba marked this pull request as ready for review May 17, 2026 22:44
@buremba buremba merged commit 9b842cd into main May 17, 2026
14 of 17 checks passed
@buremba buremba deleted the feat/auth-cookie-pivot branch May 17, 2026 22:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants