Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
267 changes: 0 additions & 267 deletions docs/plans/personal-mode-auth.md

This file was deleted.

2 changes: 1 addition & 1 deletion packages/owletto
72 changes: 72 additions & 0 deletions packages/server/src/auth/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Restrict PAT exchange before minting full sessions

When a user creates a scoped PAT (for example mcp:read for CI or an external MCP client), this endpoint now accepts that token and mints an unrestricted Better Auth web session for the PAT's user. That bypasses the existing authSource === 'pat' guard on privileged web-only actions such as creating new server tokens, because the follow-up request is authenticated as a normal session rather than as the original scoped PAT. The exchange should be limited to the bootstrap/local handoff token or otherwise enforce high-trust scopes/purpose before creating a browser session.

Useful? React with 👍 / 👎.

if (!session?.token) {
return c.json({ error: 'session_create_failed', error_description: 'failed to mint session' }, 500);
}

// Match Better Auth's cookie shape: `<token>.<base64(HMAC-SHA256(token, secret))>`,
// 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('; '));

Comment on lines +260 to +267
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve cross-subdomain cookie attributes

In hosted deployments with AUTH_COOKIE_DOMAIN enabled, normal Better Auth sign-in sets the session cookie for the shared domain so browsers on org subdomains can call the canonical app/API host. This manually constructed cookie is host-only because it never adds the configured Domain, so a token exchanged on acme.lobu.ai will not authenticate subsequent credentialed requests to app.lobu.ai, breaking the cross-subdomain flow that the auth config explicitly supports.

Useful? React with 👍 / 👎.

const rawNext = c.req.query('next') ?? '/';
const safeNext = rawNext.startsWith('/') && !rawNext.startsWith('//') ? rawNext : '/';
return c.redirect(safeNext, 302);
});

export { credentialRoutes };
66 changes: 0 additions & 66 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 `:<port>` 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(
'*',
Expand Down
26 changes: 0 additions & 26 deletions packages/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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
Expand Down
51 changes: 5 additions & 46 deletions packages/server/src/start-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 });

Expand Down Expand Up @@ -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.
Expand All @@ -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');
});
}

Expand Down Expand Up @@ -394,10 +368,9 @@ async function ensureBootstrapPat(dbUrl: string): Promise<void> {
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 }]
>`
Expand Down Expand Up @@ -437,20 +410,6 @@ async function ensureBootstrapPat(dbUrl: string): Promise<void> {
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'
Expand Down
15 changes: 0 additions & 15 deletions packages/server/src/utils/loopback.ts

This file was deleted.

Loading
Loading