From ad92373b4ba6cef68fe7bf2dbdb8676b3aa1aa2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Tue, 19 May 2026 23:59:25 +0100 Subject: [PATCH 1/7] fix(server): mount /lobu prefix in PGlite assembly (parity with server.ts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start-local.ts called initLobuGateway() but threw away the returned Hono app, so the embedded gateway's public Agent API (/lobu/api/v1/agents/*), worker gateway, MCP proxy, and bundled API docs were all unreachable in PGlite mode — every call returned 404. server.ts already mounts the same app at /lobu (PR #637); this aligns the PGlite entrypoint. Reproducer: Before: GET /lobu/health -> 404 After: GET /lobu/health -> 200 --- packages/server/src/start-local.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/server/src/start-local.ts b/packages/server/src/start-local.ts index 27dabff4c..dff205a3a 100644 --- a/packages/server/src/start-local.ts +++ b/packages/server/src/start-local.ts @@ -168,7 +168,7 @@ async function main() { const { bootTaskScheduler } = await import('./scheduled/jobs'); await initWorkspaceProvider(); - await initLobuGateway(); + const lobuApp = await initLobuGateway(); const env = getEnvFromProcess(); const taskScheduler = await bootTaskScheduler(getLobuCoreServices(), env); @@ -202,6 +202,14 @@ async function main() { Object.assign(c.env, env); return next(); }); + // Mount the embedded Lobu gateway under /lobu (mirrors server.ts:199-202). + // Without this, the public Agent API (`/lobu/api/v1/agents/*`) and bundled + // docs are 404 in PGlite mode — only the org-scoped REST app at `/` works. + // This was the missing piece behind PR #637, which only fixed the Postgres + // entrypoint. + if (lobuApp) { + wrapper.route('/lobu', lobuApp); + } wrapper.route('/', mainApp); const honoListener = getRequestListener(wrapper.fetch); From 10bb63becff6822f93eacb7cf5cab0a6ae84a791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Tue, 19 May 2026 23:59:40 +0100 Subject: [PATCH 2/7] fix(auth): accept owl_pat_ PATs in embedded Agent API auth bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lobuApp middleware only hydrated (user, session) from a Better Auth session (cookie or bearer session-token); owl_pat_* personal access tokens were ignored, so every /lobu/api/v1/agents/* call authenticated with a PAT minted by 'lobu token create' (or returned by /api/local-init's device_token) fell through to the unauthenticated path and the embedded authProvider returned null. The qmsum-demo benchmark worked around this by forging a Better Auth session cookie from BETTER_AUTH_SECRET. Extend the middleware to verify Authorization: Bearer owl_pat_* tokens via PersonalAccessTokenService.verify, look up the bound user, and synthesize the same (user, session) shape the Better Auth path produces. The downstream org-context middleware now honours an org id pinned on the PAT (PAT minted for org A must run against org A) before falling back to the user's default membership. This fixes both PGlite and Postgres assemblies — they share this auth path. Reproducer (PGlite): Before: GET /lobu/api/v1/agents -H 'Authorization: Bearer owl_pat_...' -> 401 After: GET /lobu/api/v1/agents -H 'Authorization: Bearer owl_pat_...' -> 200 After: GET /lobu/api/v1/agents -H 'Authorization: Bearer owl_pat_BAD' -> 401 --- packages/server/src/lobu/gateway.ts | 91 ++++++++++++++++++++++++----- 1 file changed, 78 insertions(+), 13 deletions(-) diff --git a/packages/server/src/lobu/gateway.ts b/packages/server/src/lobu/gateway.ts index d3a81e9e2..8ee8692e2 100644 --- a/packages/server/src/lobu/gateway.ts +++ b/packages/server/src/lobu/gateway.ts @@ -12,6 +12,7 @@ import path from 'node:path'; import type { Hono } from 'hono'; import { Hono as HonoApp } from 'hono'; import { createAuth } from '../auth'; +import { PersonalAccessTokenService } from '../auth/tokens'; import { ApiPlatform } from '../gateway/api/platform'; import { createGatewayApp } from '../gateway/cli/gateway'; import { ChatInstanceManager } from '../gateway/connections/chat-instance-manager'; @@ -268,6 +269,69 @@ export async function initLobuGateway(): Promise { // Lobu auth routes fall back to their own unauthenticated handling. } + // Personal access tokens (`owl_pat_*`) are not Better Auth session + // tokens, so `auth.api.getSession` above ignores them. Without this + // bridge, every `/lobu/api/v1/agents/*` call authenticated with a PAT + // minted by `lobu token create` falls through to the unauthenticated + // path and the embedded `authProvider` returns null. Resolve the PAT + // against `personal_access_tokens` directly and synthesise the same + // `(user, session)` context the Better Auth session would have set, + // so the downstream `authProvider` and `verifySettingsSession` paths + // treat it as a first-class identity. + if (!c.get('user')) { + const authHeader = c.req.header('Authorization'); + if (authHeader?.startsWith('Bearer ')) { + const token = authHeader.slice(7); + if (token.startsWith('owl_pat_')) { + try { + const sql = getDb(); + const patInfo = await new PersonalAccessTokenService(sql).verify(token); + if (patInfo?.userId) { + const rows = (await sql` + SELECT id, name, email, "emailVerified" + FROM "user" + WHERE id = ${patInfo.userId} + LIMIT 1 + `) as unknown as Array<{ + id: string; + name: string; + email: string; + emailVerified: boolean; + }>; + const userRow = rows[0]; + if (userRow) { + const expiresAt = + patInfo.expiresAt === Number.MAX_SAFE_INTEGER + ? new Date(Date.now() + 86_400_000) + : new Date(patInfo.expiresAt * 1000); + c.set('user', { + id: userRow.id, + name: userRow.name, + email: userRow.email, + emailVerified: userRow.emailVerified, + }); + c.set('session', { + id: `pat:${patInfo.clientId}`, + userId: userRow.id, + token, + expiresAt, + activeOrganizationId: patInfo.organizationId ?? null, + }); + if (patInfo.organizationId) { + c.set('organizationId', patInfo.organizationId); + } + } + } + } catch (err) { + logger.warn( + { err: err instanceof Error ? err.message : String(err) }, + '[Lobu] PAT verification failed' + ); + } + } + } + } + await next(); }); @@ -287,21 +351,22 @@ export async function initLobuGateway(): Promise { await next(); return; } - // Skip if a higher-priority middleware already set the org id. - if (c.get('organizationId')) { - await next(); - return; - } - let orgId: string | null; - try { - orgId = await resolveDefaultOrgId(user.id); - } catch { - return c.json({ error: 'Unable to resolve organization membership' }, 503); - } + // PAT-hydration middleware above sets `organizationId` when the PAT + // is bound to one. Honor that pin first so the org-scoped stores see + // the same tenant the PAT was minted for; only fall back to the user's + // default membership when no pin exists. + let orgId: string | null = (c.get('organizationId') as string | null) ?? null; if (!orgId) { - return c.json({ error: 'No organization membership found' }, 404); + try { + orgId = await resolveDefaultOrgId(user.id); + } catch { + return c.json({ error: 'Unable to resolve organization membership' }, 503); + } + if (!orgId) { + return c.json({ error: 'No organization membership found' }, 404); + } + c.set('organizationId', orgId); } - c.set('organizationId', orgId); await orgContext.run({ organizationId: orgId }, () => next()); }); From d504794b67ba60b9aebedbb18951bd3bc4beedb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Wed, 20 May 2026 00:22:09 +0100 Subject: [PATCH 3/7] fix(auth): harden embedded Agent API auth bridge (codex #1, #2, #3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 codex review of PR #940 surfaced three defects in the PAT bridge added by 10bb63be (`fix(auth): accept owl_pat_ PATs in embedded Agent API auth bridge`). All three live in the same middleware closure, so fix them together in one extracted `createLobuAuthBridge()` factory. #1 (HIGH) — missing tenant-membership check. After PAT verification the bridge synthesised (user, session) and set `organizationId = patInfo.organizationId` without checking the user was still a member of that org. The canonical REST path enforces this at `workspace/multi-tenant.ts:425` and returns 403 `forbidden`. Mirror that: query the `member` table for `(userId, organizationId)`; reject with the same 403 shape when missing. #2 (MED) — cookie precedence over invalid PAT. The original ordering hydrated Better Auth first and only attempted PAT validation inside an `if (!c.get('user'))` guard. A request carrying both a valid session cookie and `Authorization: Bearer owl_pat_` therefore authenticated as the cookie user and the invalid PAT was never challenged. Reverse the order: when the `Authorization` header carries `Bearer owl_pat_*`, the PAT path is authoritative — failure short-circuits with 401 regardless of cookie. Better Auth only runs when the header is absent or non-PAT. #3 (MED) — null-org PAT silent re-scoping. `personal_access_tokens.organization_id` is `ON DELETE SET NULL`; a PAT minted for a since-deleted org would fall through to `resolveDefaultOrgId(userId)` and silently bind to the user's earliest membership. Treat PATs with `organizationId === null` as invalid on this path and return 401 with a message pointing at `lobu token`. Refactor: extract the bridge from the closure inside `initLobuGateway` into an exported `createLobuAuthBridge()` factory. The behaviour change is what the bullets above describe; the factory exists so the next commit can exercise the bridge from integration tests without bootstrapping the full gateway. --- packages/server/src/lobu/gateway.ts | 231 ++++++++++++++++++---------- 1 file changed, 151 insertions(+), 80 deletions(-) diff --git a/packages/server/src/lobu/gateway.ts b/packages/server/src/lobu/gateway.ts index 8ee8692e2..1ae8b5e7e 100644 --- a/packages/server/src/lobu/gateway.ts +++ b/packages/server/src/lobu/gateway.ts @@ -114,6 +114,156 @@ function ensureEmbeddedGatewaySecrets(): void { } } +/** + * Auth bridge middleware for the embedded Lobu app. + * + * Wires three identity sources into the (user, session, organizationId) + * context that downstream `authProvider` reads: + * + * 1. Better Auth session (cookie or bearer session-token) — original path. + * 2. Personal Access Token (`Authorization: Bearer owl_pat_*`) — needed so + * `lobu chat` / device-flow PATs reach `/lobu/api/v1/agents/*`. + * 3. Tenant membership check — a PAT for org A must verify the user is still + * a member of org A; the canonical pattern lives at + * `workspace/multi-tenant.ts:425`. + * + * PAT validation runs BEFORE Better Auth so a stale/invalid PAT in the + * `Authorization` header cannot be silently masked by a still-valid session + * cookie. If the header carries an `owl_pat_*` value, that path is + * authoritative — invalid PAT short-circuits with 401 regardless of cookie. + * + * Exported for tests; production wires it via `lobuApp.use('*', …)`. + */ +export function createLobuAuthBridge() { + return async (c: any, next: any) => { + c.set('user', null); + c.set('session', null); + + const authHeader = c.req.header('Authorization'); + const bearerValue = + authHeader && authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : null; + const isPatBearer = bearerValue !== null && bearerValue.startsWith('owl_pat_'); + + // 1. PAT path — authoritative when the Authorization header carries + // `Bearer owl_pat_*`. Validate first so an invalid PAT cannot fall + // through to a cooked Better Auth cookie (codex finding #2). On any + // failure return 401 immediately rather than masking it with a + // different identity. + if (isPatBearer) { + let patInfo: Awaited> | null = null; + try { + const sql = getDb(); + patInfo = await new PersonalAccessTokenService(sql).verify(bearerValue); + } catch (err) { + logger.warn( + { err: err instanceof Error ? err.message : String(err) }, + '[Lobu] PAT verification failed' + ); + return c.json({ error: 'invalid_token', error_description: 'PAT verification failed' }, 401); + } + + if (!patInfo?.userId) { + return c.json( + { error: 'invalid_token', error_description: 'PAT is invalid, expired, or revoked' }, + 401 + ); + } + + // Reject PATs with null organization_id on the embedded Agent API + // path (codex finding #3). The FK is `ON DELETE SET NULL`, so a PAT + // bound to a since-deleted org would otherwise silently re-resolve to + // an unrelated org via `resolveDefaultOrgId`. + if (!patInfo.organizationId) { + return c.json( + { + error: 'invalid_token', + error_description: + 'PAT is not scoped to an organization — re-mint via `lobu token`', + }, + 401 + ); + } + + const sql = getDb(); + const rows = (await sql` + SELECT id, name, email, "emailVerified" + FROM "user" + WHERE id = ${patInfo.userId} + LIMIT 1 + `) as unknown as Array<{ + id: string; + name: string; + email: string; + emailVerified: boolean; + }>; + const userRow = rows[0]; + if (!userRow) { + return c.json( + { error: 'invalid_token', error_description: 'PAT user no longer exists' }, + 401 + ); + } + + // Enforce tenant membership (codex finding #1). Mirrors the canonical + // check in workspace/multi-tenant.ts: 403 + `forbidden` when the PAT + // owner is no longer a member of the org the PAT is bound to. + const memberRows = (await sql` + SELECT 1 + FROM "member" + WHERE "userId" = ${userRow.id} + AND "organizationId" = ${patInfo.organizationId} + LIMIT 1 + `) as unknown as Array<{ '?column?': number }>; + if (memberRows.length === 0) { + return c.json( + { + error: 'forbidden', + error_description: 'Token owner is not a member of this organization', + }, + 403 + ); + } + + const expiresAt = + patInfo.expiresAt === Number.MAX_SAFE_INTEGER + ? new Date(Date.now() + 86_400_000) + : new Date(patInfo.expiresAt * 1000); + c.set('user', { + id: userRow.id, + name: userRow.name, + email: userRow.email, + emailVerified: userRow.emailVerified, + }); + c.set('session', { + id: `pat:${patInfo.clientId}`, + userId: userRow.id, + token: bearerValue, + expiresAt, + activeOrganizationId: patInfo.organizationId, + }); + c.set('organizationId', patInfo.organizationId); + + await next(); + return; + } + + // 2. Better Auth path — cookie or Better-Auth bearer session-token. + // Only runs when the request did NOT present an owl_pat_* bearer. + try { + const auth = await createAuth(c.env, c.req.raw); + const session = await auth.api.getSession({ headers: c.req.raw.headers }); + if (session?.user && session.session) { + c.set('user', session.user); + c.set('session', session.session); + } + } catch { + // Lobu auth routes fall back to their own unauthenticated handling. + } + + await next(); + }; +} + /** * Initialize the embedded Lobu gateway. * Returns the Hono app to mount, or null if DATABASE_URL is not configured. @@ -254,86 +404,7 @@ export async function initLobuGateway(): Promise { // Embedded Lobu auth routes need the Lobu Better Auth session, but they are mounted // outside the main app's auth middleware. Hydrate the shared user/session context here. lobuApp = new HonoApp<{ Bindings: Env }>(); - lobuApp.use('*', async (c: any, next: any) => { - c.set('user', null); - c.set('session', null); - - try { - const auth = await createAuth(c.env, c.req.raw); - const session = await auth.api.getSession({ headers: c.req.raw.headers }); - if (session?.user && session.session) { - c.set('user', session.user); - c.set('session', session.session); - } - } catch { - // Lobu auth routes fall back to their own unauthenticated handling. - } - - // Personal access tokens (`owl_pat_*`) are not Better Auth session - // tokens, so `auth.api.getSession` above ignores them. Without this - // bridge, every `/lobu/api/v1/agents/*` call authenticated with a PAT - // minted by `lobu token create` falls through to the unauthenticated - // path and the embedded `authProvider` returns null. Resolve the PAT - // against `personal_access_tokens` directly and synthesise the same - // `(user, session)` context the Better Auth session would have set, - // so the downstream `authProvider` and `verifySettingsSession` paths - // treat it as a first-class identity. - if (!c.get('user')) { - const authHeader = c.req.header('Authorization'); - if (authHeader?.startsWith('Bearer ')) { - const token = authHeader.slice(7); - if (token.startsWith('owl_pat_')) { - try { - const sql = getDb(); - const patInfo = await new PersonalAccessTokenService(sql).verify(token); - if (patInfo?.userId) { - const rows = (await sql` - SELECT id, name, email, "emailVerified" - FROM "user" - WHERE id = ${patInfo.userId} - LIMIT 1 - `) as unknown as Array<{ - id: string; - name: string; - email: string; - emailVerified: boolean; - }>; - const userRow = rows[0]; - if (userRow) { - const expiresAt = - patInfo.expiresAt === Number.MAX_SAFE_INTEGER - ? new Date(Date.now() + 86_400_000) - : new Date(patInfo.expiresAt * 1000); - c.set('user', { - id: userRow.id, - name: userRow.name, - email: userRow.email, - emailVerified: userRow.emailVerified, - }); - c.set('session', { - id: `pat:${patInfo.clientId}`, - userId: userRow.id, - token, - expiresAt, - activeOrganizationId: patInfo.organizationId ?? null, - }); - if (patInfo.organizationId) { - c.set('organizationId', patInfo.organizationId); - } - } - } - } catch (err) { - logger.warn( - { err: err instanceof Error ? err.message : String(err) }, - '[Lobu] PAT verification failed' - ); - } - } - } - } - - await next(); - }); + lobuApp.use('*', createLobuAuthBridge()); // Resolve the signed-in user's primary org and wrap the rest of the // request in orgContext.run() so Postgres-backed stores (which read the From 68c16800bc7b5264b1976103cc50fd89d39e8bb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Wed, 20 May 2026 00:26:22 +0100 Subject: [PATCH 4/7] test(auth): cover embedded Agent API auth bridge (codex #4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round-2 codex review of PR #940 noted that existing PAT coverage hits the MCP routes and the token service, but not the embedded /lobu/api/v1/agents/* auth bridge introduced by 10bb63be. Add a focused integration suite that mounts `createLobuAuthBridge` (exported in the previous commit) on a minimal Hono app and exercises every contract the bridge has to honour. 11 tests across four describe blocks: - Happy path: valid PAT → 200, organizationId pinned to the PAT's org. - Rejection cases: unknown hash, expired, revoked, missing owl_pat_ prefix, empty Authorization, non-Bearer scheme — all 401 (with the bridge's `invalid_token` shape on actual PATs, and the test handler's `no-user` shape on tokens the bridge correctly ignores). - Cookie precedence (codex #2): valid session cookie + invalid PAT → 401 invalid_token, not 200 via cookie fallback. - Tenant membership (codex #1): valid PAT for an org the user has been removed from → 403 forbidden, mirroring multi-tenant.ts:425. Plus a defensive variant for a PAT minted against an org the user never joined. - Null org PAT (codex #3): valid PAT whose organization_id was set to NULL after creation (mirrors the ON DELETE SET NULL collapse path) → 401 invalid_token, not silent re-resolution to the user's earliest membership via resolveDefaultOrgId. Run with LOBU_TEST_BACKEND=pglite — no external Postgres required. --- .../integration/lobu/gateway-auth.test.ts | 276 ++++++++++++++++++ 1 file changed, 276 insertions(+) create mode 100644 packages/server/src/__tests__/integration/lobu/gateway-auth.test.ts diff --git a/packages/server/src/__tests__/integration/lobu/gateway-auth.test.ts b/packages/server/src/__tests__/integration/lobu/gateway-auth.test.ts new file mode 100644 index 000000000..46d77d354 --- /dev/null +++ b/packages/server/src/__tests__/integration/lobu/gateway-auth.test.ts @@ -0,0 +1,276 @@ +/** + * Integration tests for the embedded Lobu Agent API auth bridge + * (`createLobuAuthBridge` in `src/lobu/gateway.ts`). + * + * These cover the codex round-2 findings on PR #940: + * + * - #1 (HIGH) tenant-membership check after PAT verification + * - #2 (MED) PAT validation runs BEFORE cookie hydration + * - #3 (MED) PATs with null `organization_id` are rejected on this path + * - #4 (LOW) no test coverage for the bridge before this file + * + * The bridge is mounted on a minimal Hono app rather than booting the full + * embedded gateway — the `(user, session, organizationId)` context the + * bridge writes is the contract downstream `authProvider` reads, so the + * test handler simply mirrors that state back as JSON and asserts on it. + */ + +import { Hono } from 'hono'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import type { Env } from '../../../index'; +import { createLobuAuthBridge } from '../../../lobu/gateway'; +import { cleanupTestDatabase, getTestDb } from '../../setup/test-db'; +import { + addUserToOrganization, + createTestOrganization, + createTestPAT, + createTestSession, + createTestUser, +} from '../../setup/test-fixtures'; + +const testEnv: Env = { + ENVIRONMENT: 'test', + DATABASE_URL: process.env.DATABASE_URL, + JWT_SECRET: 'test-jwt-secret-for-testing-only', + BETTER_AUTH_SECRET: 'test-auth-secret-for-testing-only', + MAX_CONSECUTIVE_FAILURES: '3', + RATE_LIMIT_ENABLED: 'false', +}; + +function buildApp(): Hono<{ Bindings: Env }> { + const app = new Hono<{ Bindings: Env }>(); + app.use('*', createLobuAuthBridge()); + // Mirror the (user, session, organizationId) the bridge populated. Tests + // hit this single handler and inspect either the JSON body (success + // shape) or the bridge's own short-circuit response (401/403). + app.get('/test', (c: any) => { + const user = c.get('user'); + const session = c.get('session'); + const organizationId = c.get('organizationId') ?? null; + if (!user) { + return c.json({ ok: false, reason: 'no-user' }, 401); + } + return c.json({ + ok: true, + userId: user.id, + sessionId: session?.id ?? null, + organizationId, + }); + }); + return app; +} + +async function fetchTest( + app: Hono<{ Bindings: Env }>, + options: { token?: string; cookie?: string; authHeader?: string } = {} +): Promise<{ status: number; body: any }> { + const headers: Record = {}; + if (options.authHeader !== undefined) { + headers.Authorization = options.authHeader; + } else if (options.token) { + headers.Authorization = `Bearer ${options.token}`; + } + if (options.cookie) { + headers.Cookie = options.cookie; + } + const res = await app.fetch(new Request('http://test.local/test', { headers }), testEnv); + const text = await res.text(); + let body: any = null; + try { + body = JSON.parse(text); + } catch { + body = text; + } + return { status: res.status, body }; +} + +describe('Lobu embedded Agent API auth bridge', () => { + let org: Awaited>; + let otherOrg: Awaited>; + let user: Awaited>; + let app: Hono<{ Bindings: Env }>; + + beforeAll(async () => { + await cleanupTestDatabase(); + org = await createTestOrganization({ name: 'PAT Bridge Org' }); + otherOrg = await createTestOrganization({ name: 'PAT Bridge Other Org' }); + user = await createTestUser({}); + await addUserToOrganization(user.id, org.id); + // user is intentionally NOT a member of otherOrg — used by the + // membership-removed test (codex #1). + }); + + beforeEach(() => { + app = buildApp(); + }); + + describe('PAT happy path', () => { + it('accepts a valid PAT and pins the bound organization', async () => { + const { token } = await createTestPAT(user.id, org.id); + + const { status, body } = await fetchTest(app, { token }); + + expect(status).toBe(200); + expect(body.ok).toBe(true); + expect(body.userId).toBe(user.id); + expect(body.organizationId).toBe(org.id); + expect(body.sessionId).toMatch(/^pat:/); + }); + }); + + describe('PAT rejection cases', () => { + it('rejects a PAT with the owl_pat_ prefix but an unknown hash', async () => { + const { status, body } = await fetchTest(app, { + token: 'owl_pat_unknown_hash_that_will_never_match', + }); + + expect(status).toBe(401); + expect(body.error).toBe('invalid_token'); + }); + + it('rejects an expired PAT', async () => { + const { token } = await createTestPAT(user.id, org.id); + const sql = getTestDb(); + await sql` + UPDATE personal_access_tokens + SET expires_at = NOW() - INTERVAL '1 hour' + WHERE user_id = ${user.id} AND organization_id = ${org.id} + `; + + const { status, body } = await fetchTest(app, { token }); + + expect(status).toBe(401); + expect(body.error).toBe('invalid_token'); + }); + + it('rejects a revoked PAT', async () => { + const { token } = await createTestPAT(user.id, org.id); + const sql = getTestDb(); + await sql` + UPDATE personal_access_tokens + SET revoked_at = NOW() + WHERE user_id = ${user.id} AND organization_id = ${org.id} + `; + + const { status, body } = await fetchTest(app, { token }); + + expect(status).toBe(401); + expect(body.error).toBe('invalid_token'); + }); + + it('treats a bearer token without the owl_pat_ prefix as not-a-PAT (falls through to Better Auth)', async () => { + // Non-PAT bearer token + no cookie → cookie hydration runs and fails → + // downstream handler sees no user and returns 401. Importantly this + // path does NOT 401 the request as "invalid PAT" — that contract is + // reserved for tokens that actually carry the owl_pat_ prefix. + const { status, body } = await fetchTest(app, { token: 'definitely_not_a_pat' }); + + expect(status).toBe(401); + // Bridge's own 401 has body.error; this 401 comes from the test + // handler (no Better Auth session resolved), so body.reason === 'no-user'. + expect(body.reason).toBe('no-user'); + }); + + it('rejects an empty Authorization header value', async () => { + // Empty Authorization is non-PAT → Better Auth runs → no user → 401. + const { status, body } = await fetchTest(app, { authHeader: '' }); + + expect(status).toBe(401); + expect(body.reason).toBe('no-user'); + }); + + it('rejects a malformed Authorization header (no Bearer scheme)', async () => { + // No Bearer prefix → non-PAT path → Better Auth → no user → 401. + const { status, body } = await fetchTest(app, { + authHeader: 'Basic dXNlcjpwYXNz', + }); + + expect(status).toBe(401); + expect(body.reason).toBe('no-user'); + }); + }); + + describe('Cookie precedence (codex #2)', () => { + it('rejects an invalid PAT even when a valid session cookie is present', async () => { + // The pre-fix bridge hydrated cookies first and only ran PAT + // validation behind `if (!c.get('user'))` — a valid cookie therefore + // masked any invalid PAT in the same request. After the fix, a + // request with `Authorization: Bearer owl_pat_*` is authoritative on + // the PAT path: invalid PAT → 401 regardless of cookie. + const session = await createTestSession(user.id); + + const { status, body } = await fetchTest(app, { + token: 'owl_pat_obviously_invalid_hash', + cookie: session.cookieHeader, + }); + + expect(status).toBe(401); + expect(body.error).toBe('invalid_token'); + }); + }); + + describe('Tenant membership (codex #1)', () => { + it('rejects a valid PAT when the user is no longer a member of the bound org', async () => { + // Mint a PAT bound to org A, then delete the (user, org A) member row + // — the PAT itself is still valid, but the user has lost membership. + // Bridge must reject with 403 `forbidden` (mirrors multi-tenant.ts:425). + const tempOrg = await createTestOrganization({ name: 'Membership Drop Org' }); + await addUserToOrganization(user.id, tempOrg.id); + const { token } = await createTestPAT(user.id, tempOrg.id); + + const sql = getTestDb(); + await sql` + DELETE FROM "member" + WHERE "userId" = ${user.id} AND "organizationId" = ${tempOrg.id} + `; + + const { status, body } = await fetchTest(app, { token }); + + expect(status).toBe(403); + expect(body.error).toBe('forbidden'); + expect(body.error_description).toContain('not a member'); + }); + + it('rejects a PAT whose user has never been a member of the bound org', async () => { + // Defensive: even a PAT minted directly for an org the user never + // joined (shouldn't happen via the supported mint path, but the row + // can land via direct SQL or a race) must fail closed. + const sql = getTestDb(); + // Use createTestPAT but against otherOrg, where user has no member row. + const { token } = await createTestPAT(user.id, otherOrg.id); + // Sanity: confirm the member row is absent. + const memberRows = (await sql` + SELECT 1 FROM "member" + WHERE "userId" = ${user.id} AND "organizationId" = ${otherOrg.id} + `) as unknown as Array; + expect(memberRows.length).toBe(0); + + const { status, body } = await fetchTest(app, { token }); + + expect(status).toBe(403); + expect(body.error).toBe('forbidden'); + }); + }); + + describe('Null org PAT (codex #3)', () => { + it('rejects a valid PAT whose organization_id is NULL', async () => { + // PATs with null org id (e.g. minted against a since-deleted org — + // `ON DELETE SET NULL`) must NOT silently re-resolve to the user's + // earliest membership on the embedded Agent API path. + const sql = getTestDb(); + const { token } = await createTestPAT(user.id, org.id); + await sql` + UPDATE personal_access_tokens + SET organization_id = NULL + WHERE user_id = ${user.id} + AND token_prefix = ${token.substring(0, 12)} + `; + + const { status, body } = await fetchTest(app, { token }); + + expect(status).toBe(401); + expect(body.error).toBe('invalid_token'); + expect(body.error_description).toContain('not scoped to an organization'); + }); + }); +}); From b3eec3cfd7b841e25a26399ecfe1293cdf9f6f87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Wed, 20 May 2026 00:40:26 +0100 Subject: [PATCH 5/7] fix(auth): case-insensitive Bearer scheme parsing per RFC 7235 (codex round-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-fix: `Authorization: bearer owl_pat_*` (lowercase scheme) failed the `header.startsWith('Bearer ')` literal match, so the PAT path was skipped and the bridge fell through to the Better Auth cookie path — a valid session cookie would silently mask an invalid/revoked PAT. RFC 7235 §2.1 makes the auth scheme token case-insensitive. Parse it that way. Token VALUE comparison stays case-sensitive — PAT hashes are. --- packages/server/src/lobu/gateway.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/server/src/lobu/gateway.ts b/packages/server/src/lobu/gateway.ts index 1ae8b5e7e..eb752005f 100644 --- a/packages/server/src/lobu/gateway.ts +++ b/packages/server/src/lobu/gateway.ts @@ -140,8 +140,14 @@ export function createLobuAuthBridge() { c.set('session', null); const authHeader = c.req.header('Authorization'); - const bearerValue = - authHeader && authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : null; + // RFC 7235 §2.1 — the auth scheme token is case-insensitive. A request + // sending `Authorization: bearer owl_pat_*` with a valid Better Auth + // cookie would otherwise skip PAT validation entirely (lowercase fails + // the `Bearer ` literal match) and fall through to the cookie path, + // silently masking an invalid/revoked PAT (codex round-2 finding). + // Token VALUE comparison stays case-sensitive — PAT hashes are. + const bearerMatch = authHeader ? /^bearer\s+(.*)$/i.exec(authHeader) : null; + const bearerValue = bearerMatch ? (bearerMatch[1] ?? '').trim() : null; const isPatBearer = bearerValue !== null && bearerValue.startsWith('owl_pat_'); // 1. PAT path — authoritative when the Authorization header carries From f2348422b3a8d1cf5eb5e24ea7a04e3be6607939 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Wed, 20 May 2026 00:40:38 +0100 Subject: [PATCH 6/7] test(auth): cover lowercase Bearer bypass + cookie-only happy path (codex round-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new tests against `createLobuAuthBridge`: 1. Lowercase `bearer` scheme + invalid PAT + valid session cookie → 401 (proves the case-insensitive parse + PAT precedence hold together; was the evasion gap before the fix). 2. Uppercase `BEARER` scheme + valid PAT → 200 (case-insensitive parse, success direction). 3. Cookie-only request (no Authorization header) → bridge reaches `next()` instead of short-circuiting with its own 401/403 (`error: 'invalid_token'` / `error: 'forbidden'` would indicate the PAT or membership path mistakenly fired). End-to-end Better Auth cookie verification is exercised by entities/member-privacy-contract.test.ts via the full app; this minimal harness only owns the bridge contract. --- .../integration/lobu/gateway-auth.test.ts | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/packages/server/src/__tests__/integration/lobu/gateway-auth.test.ts b/packages/server/src/__tests__/integration/lobu/gateway-auth.test.ts index 46d77d354..004033151 100644 --- a/packages/server/src/__tests__/integration/lobu/gateway-auth.test.ts +++ b/packages/server/src/__tests__/integration/lobu/gateway-auth.test.ts @@ -252,6 +252,78 @@ describe('Lobu embedded Agent API auth bridge', () => { }); }); + describe('Case-insensitive Bearer scheme (codex round-3)', () => { + it('rejects an invalid PAT presented with a lowercase `bearer` scheme even when a valid cookie is set', async () => { + // Pre-fix: `header.startsWith("Bearer ")` failed on lowercase scheme → + // bridge skipped PAT validation, fell through to the cookie path, and + // authenticated the request via the valid session cookie. That hid an + // invalid/revoked PAT — an evasion gap. RFC 7235 §2.1 makes the scheme + // token case-insensitive; the bridge must parse it that way. + const session = await createTestSession(user.id); + + const { status, body } = await fetchTest(app, { + authHeader: 'bearer owl_pat_obviously_invalid_hash', + cookie: session.cookieHeader, + }); + + expect(status).toBe(401); + expect(body.error).toBe('invalid_token'); + }); + + it('accepts a valid PAT presented with an uppercase `BEARER` scheme', async () => { + // Same case-insensitivity, success direction: an all-uppercase scheme + // token must still hit the PAT path and resolve identity. + const { token } = await createTestPAT(user.id, org.id); + + const { status, body } = await fetchTest(app, { + authHeader: `BEARER ${token}`, + }); + + expect(status).toBe(200); + expect(body.ok).toBe(true); + expect(body.userId).toBe(user.id); + expect(body.organizationId).toBe(org.id); + }); + }); + + describe('Cookie-only path does not short-circuit (codex round-3)', () => { + it('lets a cookie-only request reach the downstream handler instead of returning a bridge-level 401/403', async () => { + // Guards the PAT-precedence change against silently regressing the + // cookie path. Better Auth's `getSession` is exercised end-to-end by + // other integration suites (see entities/member-privacy-contract.test.ts); + // here we assert the *bridge contract* — when no Authorization header + // is present, the bridge: + // 1. does NOT return its own 401/403 (`error: 'invalid_token'` / + // `error: 'forbidden'`) — those are reserved for the PAT path + // and the tenant-membership check. + // 2. invokes the Better Auth path and reaches `await next()`, so the + // downstream handler runs. + // If the test handler 401s with `reason: 'no-user'`, that 401 came + // from the downstream handler (Better Auth didn't resolve a session in + // this minimal harness — the full app does, but the bridge contract + // here is "did we reach next()", not "did Better Auth verify the cookie"). + const session = await createTestSession(user.id); + + const { status, body } = await fetchTest(app, { cookie: session.cookieHeader }); + + // Bridge-level rejections carry an `error` field with a specific code. + // Reaching `next()` means the test handler answered — its 401 carries + // `reason: 'no-user'` instead. + expect(body.error).toBeUndefined(); + if (status === 200) { + // Full Better Auth integration would land here. + expect(body.ok).toBe(true); + expect(body.userId).toBe(user.id); + } else { + // Bridge reached next() but Better Auth didn't materialize a user in + // this minimal harness. Still proves the bridge didn't reject — the + // PAT-precedence path didn't run, no membership check fired. + expect(status).toBe(401); + expect(body.reason).toBe('no-user'); + } + }); + }); + describe('Null org PAT (codex #3)', () => { it('rejects a valid PAT whose organization_id is NULL', async () => { // PATs with null org id (e.g. minted against a since-deleted org — From 44ef53dad25a4ddd4d4368c3a4a89bd874752732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Wed, 20 May 2026 00:59:21 +0100 Subject: [PATCH 7/7] fix(auth): case-insensitive PAT prefix detection (codex round-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the Bearer scheme fix at the inner prefix check: `Bearer OWL_PAT_*` now flows through PAT validation instead of falling through to cookie auth. Token value handed to verify() is unchanged — PAT hashes stay byte-exact. --- packages/server/src/lobu/gateway.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/server/src/lobu/gateway.ts b/packages/server/src/lobu/gateway.ts index eb752005f..3b70229d3 100644 --- a/packages/server/src/lobu/gateway.ts +++ b/packages/server/src/lobu/gateway.ts @@ -148,7 +148,12 @@ export function createLobuAuthBridge() { // Token VALUE comparison stays case-sensitive — PAT hashes are. const bearerMatch = authHeader ? /^bearer\s+(.*)$/i.exec(authHeader) : null; const bearerValue = bearerMatch ? (bearerMatch[1] ?? '').trim() : null; - const isPatBearer = bearerValue !== null && bearerValue.startsWith('owl_pat_'); + // PAT prefix detection is case-insensitive so `Bearer OWL_PAT_*` is + // recognized as a PAT and validated, not silently masked behind cookie + // auth (codex round-3 finding). The token VALUE handed to verify() is + // unchanged — PAT hashes are case-sensitive on the bytes. + const isPatBearer = + bearerValue !== null && bearerValue.slice(0, 8).toLowerCase() === 'owl_pat_'; // 1. PAT path — authoritative when the Authorization header carries // `Bearer owl_pat_*`. Validate first so an invalid PAT cannot fall