From 11a2644682a558280fe9cbef1e135835f9398593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Tue, 26 May 2026 22:59:59 +0100 Subject: [PATCH 1/2] fix(auth): deliver the session cookie to the Owletto extension iframe (CHIPS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit owletto-web is embedded in the extension side panel as a CROSS-SITE iframe (top-level chrome-extension://). The deep-link session cookie was SameSite=Lax, which the browser withholds on cross-site iframe loads, so the embedded app rendered signed-out (sign-in page / Google 403 on the in-iframe login). - POST /api/exchange-token: new handler (token in the body) that mints the session cookie as SameSite=None; Secure; Partitioned (CHIPS). The extension's iframe bootstrap POSTs here from inside its OWN partition, so the cookie is keyed to the chrome-extension:// top-level — delivered on same-partition iframe loads, isolated from every other site's partition (no CSRF widening), and it survives third-party-cookie deprecation. - GET /api/exchange-token and /api/local-init keep the first-party SameSite=Lax cookie unchanged (CLI / menu-bar deep-link runs in a top-level tab). - GET /api/extension-bootstrap: serves the in-iframe bootstrap page that reads the PAT from the URL fragment, strips it from history, and POSTs it — so the PAT never lands in a request URL or history entry. - Test: POST -> None;Secure;Partitioned, GET -> Lax (never Partitioned/None), 400/401 rejects, and the bootstrap HTML. Adds a postForm test helper. Backward compatible: the new routes are additive and the existing GET/local-init cookie is unchanged. Pairs with the extension change in lobu-ai/owletto#233; the submodule pointer is bumped in a follow-up after that merges. --- .../src/__tests__/setup/test-helpers.ts | 30 ++++ .../__tests__/exchange-token-cookie.test.ts | 66 +++++++++ packages/server/src/auth/routes.ts | 128 ++++++++++++++++-- 3 files changed, 211 insertions(+), 13 deletions(-) create mode 100644 packages/server/src/auth/__tests__/exchange-token-cookie.test.ts diff --git a/packages/server/src/__tests__/setup/test-helpers.ts b/packages/server/src/__tests__/setup/test-helpers.ts index 6c5cca3cd..b65e074b8 100644 --- a/packages/server/src/__tests__/setup/test-helpers.ts +++ b/packages/server/src/__tests__/setup/test-helpers.ts @@ -111,6 +111,36 @@ export const post = (path: string, options?: Parameters[2]) export const del = (path: string, options?: Omit[2], 'body'>) => testRequest('DELETE', path, options); +/** + * POST an `application/x-www-form-urlencoded` body — what a browser
+ * submit sends. Exercises routes that read via `c.req.parseBody()` (e.g. the + * extension-bootstrap → /api/exchange-token handoff), which JSON bodies don't. + */ +export async function postForm( + path: string, + form: Record, + options?: { cookie?: string; env?: Partial } +): Promise { + await ensureWorkspaceProvider(); + const headers: Record = { + 'Content-Type': 'application/x-www-form-urlencoded', + }; + if (options?.cookie) headers.Cookie = options.cookie; + const request = new Request(`http://localhost${path}`, { + method: 'POST', + headers, + body: new URLSearchParams(form).toString(), + }); + const env = { ...testEnv, ...options?.env }; + const response = await app.fetch(request, env); + return { + status: response.status, + headers: response.headers, + json: () => response.json(), + text: () => response.text(), + }; +} + // ============================================ // MCP Session Management // ============================================ diff --git a/packages/server/src/auth/__tests__/exchange-token-cookie.test.ts b/packages/server/src/auth/__tests__/exchange-token-cookie.test.ts new file mode 100644 index 000000000..560e9260e --- /dev/null +++ b/packages/server/src/auth/__tests__/exchange-token-cookie.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { cleanupTestDatabase } from '../../__tests__/setup/test-db'; +import { + addUserToOrganization, + createTestOrganization, + createTestPAT, + createTestUser, +} from '../../__tests__/setup/test-fixtures'; +import { get, postForm } from '../../__tests__/setup/test-helpers'; + +// The Owletto extension embeds owletto-web as a cross-site iframe (top-level +// chrome-extension://). Lax cookies are withheld there, so the deep-link cookie +// posture differs by entry point: the extension iframe (POST, set inside its +// own partition) needs CHIPS Partitioned; SameSite=None; the CLI/menu-bar +// first-party deep-link (GET, top-level tab) keeps Lax. See auth/routes.ts. +describe('exchange-token cookie posture', () => { + beforeEach(async () => { + await cleanupTestDatabase(); + }); + + async function patForNewUser(slug: string, email: string): Promise { + const org = await createTestOrganization({ slug }); + const user = await createTestUser({ email }); + await addUserToOrganization(user.id, org.id, 'owner'); + const { token } = await createTestPAT(user.id, org.id, { scope: 'profile:read' }); + return token; + } + + it('POST mints a CHIPS partitioned cross-site cookie (extension iframe)', async () => { + const token = await patForNewUser('xt-post-org', 'xt-post@test.example.com'); + const res = await postForm('/api/exchange-token', { token, next: '/#worker=abc' }); + + expect(res.status).toBe(302); + expect(res.headers.get('location')).toBe('/#worker=abc'); + const cookie = res.headers.get('set-cookie') ?? ''; + expect(cookie).toMatch(/SameSite=None/i); + expect(cookie).toMatch(/Secure/i); + expect(cookie).toMatch(/Partitioned/i); + }); + + it('GET keeps a first-party Lax cookie — never Partitioned/None (CLI/menu-bar)', async () => { + const token = await patForNewUser('xt-get-org', 'xt-get@test.example.com'); + const res = await get(`/api/exchange-token?token=${encodeURIComponent(token)}&next=/`); + + expect(res.status).toBe(302); + const cookie = res.headers.get('set-cookie') ?? ''; + expect(cookie).toMatch(/SameSite=Lax/i); + expect(cookie).not.toMatch(/Partitioned/i); + expect(cookie).not.toMatch(/SameSite=None/i); + }); + + it('rejects a missing or invalid token', async () => { + expect((await postForm('/api/exchange-token', { next: '/' })).status).toBe(400); + expect((await postForm('/api/exchange-token', { token: 'owl_pat_nope', next: '/' })).status).toBe(401); + }); + + it('serves the in-iframe bootstrap page (POSTs the token, strips the fragment)', async () => { + const res = await get('/api/extension-bootstrap'); + expect(res.status).toBe(200); + expect(res.headers.get('content-type') ?? '').toMatch(/text\/html/i); + const html = await res.text(); + expect(html).toContain('/api/exchange-token'); + expect(html).toContain('location.hash'); + expect(html).toContain('replaceState'); + }); +}); diff --git a/packages/server/src/auth/routes.ts b/packages/server/src/auth/routes.ts index 5e01e338e..ae826ef99 100644 --- a/packages/server/src/auth/routes.ts +++ b/packages/server/src/auth/routes.ts @@ -280,11 +280,33 @@ function assertLoopbackClient( /** * Mint a Better Auth session for a user and return the Set-Cookie value. - * Centralised so /exchange-token and /local-init produce identical cookies. + * + * `partitioned` controls the cross-site posture of the cookie: + * - false (default): SameSite=Lax (+ Secure on https). For first-party + * callers — the CLI/menu-bar deep-link (GET /exchange-token) and + * /local-init both run in a top-level browser tab where Lax is correct. + * - true: SameSite=None; Secure; Partitioned (CHIPS). For the Owletto + * extension's side-panel iframe (POST /exchange-token), which is a + * CROSS-SITE iframe (top-level origin chrome-extension://…). A Lax cookie + * is withheld on cross-site iframe loads, so the embedded app rendered + * signed-out. Because the POST runs INSIDE the iframe, the cookie's + * partition key is the chrome-extension:// top-level: it's delivered on + * later same-partition iframe requests and ISOLATED from every other site's + * partition — so it does NOT widen CSRF the way an unpartitioned + * SameSite=None cookie would, and it survives third-party-cookie + * deprecation. Verified with cross-site-iframe cookie probes from the + * extension top-level (Lax → withheld; None;Secure;Partitioned set in the + * partition → delivered same-partition, absent elsewhere). + * + * SameSite=None/Partitioned require Secure, which the browser only honours on a + * secure context (https or http://localhost). On a plain-http non-loopback + * self-host the partitioned variant can't be set, so it falls back to Lax — the + * extension's embedded view is unsupported there (first-party use is unaffected). */ async function mintSessionCookieValue( c: Context<{ Bindings: Env }>, - userId: string + userId: string, + opts: { partitioned?: boolean } = {} ): Promise<{ cookieName: string; cookieHeader: string; sessionToken: string } | { error: string }> { const secret = c.env.BETTER_AUTH_SECRET; if (!secret) return { error: 'BETTER_AUTH_SECRET not set' }; @@ -300,16 +322,26 @@ async function mintSessionCookieValue( // __Secure- prefix iff the canonical baseURL is https — matches whatever // Better Auth would set during normal sign-in even when TLS is terminated // by a reverse proxy and the bind itself speaks plain HTTP. - const isHttps = resolveBaseUrl({ request: c.req.raw }).startsWith('https://'); + const baseUrl = resolveBaseUrl({ request: c.req.raw }); + const isHttps = baseUrl.startsWith('https://'); const cookieName = isHttps ? '__Secure-better-auth.session_token' : 'better-auth.session_token'; + + const isLocalhost = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(:|\/|$)/i.test(baseUrl); + const secureContext = isHttps || isLocalhost; + const partitioned = Boolean(opts.partitioned) && secureContext; const parts = [ `${cookieName}=${cookieValue}`, 'Path=/', 'HttpOnly', - 'SameSite=Lax', + partitioned ? 'SameSite=None' : 'SameSite=Lax', `Max-Age=${60 * 60 * 24 * 7}`, ]; - if (isHttps) parts.push('Secure'); + if (partitioned) { + parts.push('Secure'); + parts.push('Partitioned'); + } else if (isHttps) { + parts.push('Secure'); + } return { cookieName, cookieHeader: parts.join('; '), sessionToken: session.token }; } @@ -347,19 +379,33 @@ async function resolveDeepLinkToken( * web UI without typing a password. `next` is restricted to relative paths * to prevent open-redirect abuse; Referrer-Policy keeps the token out of * the next page's Referer. + * + * Shared by GET (CLI/menu-bar deep-link in a top-level browser tab, token in + * the query string) and POST (the Owletto extension's iframe bootstrap, token + * in the request body so it never lands in a URL — see extension-bootstrap). + * The Set-Cookie lands in whatever partition the request runs in: a top-level + * tab → first-party; the extension iframe → the chrome-extension partition, + * which is exactly what the CHIPS Partitioned cookie needs. */ -credentialRoutes.get('/exchange-token', async (c) => { +async function handleExchangeToken( + c: Context<{ Bindings: Env }>, + token: string | undefined, + rawNext: string | undefined, + // true only for the extension iframe (POST): mint a CHIPS Partitioned cookie + // so it's delivered in the cross-site iframe. GET (first-party tab) → false. + partitioned: boolean +) { c.header('Referrer-Policy', 'no-referrer'); - const token = c.req.query('token')?.trim(); - if (!token) { + const trimmed = token?.trim(); + if (!trimmed) { return c.json( - { error: 'missing_token', error_description: 'token query param is required' }, + { error: 'missing_token', error_description: 'token is required' }, 400 ); } - const userId = await resolveDeepLinkToken(c, token); + const userId = await resolveDeepLinkToken(c, trimmed); if (!userId) { return c.json( { error: 'invalid_token', error_description: 'token is invalid, expired, or revoked' }, @@ -367,7 +413,7 @@ credentialRoutes.get('/exchange-token', async (c) => { ); } - const minted = await mintSessionCookieValue(c, userId); + const minted = await mintSessionCookieValue(c, userId, { partitioned }); if ('error' in minted) { return c.json( { error: 'session_create_failed', error_description: minted.error }, @@ -376,9 +422,65 @@ credentialRoutes.get('/exchange-token', async (c) => { } c.header('Set-Cookie', minted.cookieHeader); - const rawNext = c.req.query('next') ?? '/'; - const safeNext = rawNext.startsWith('/') && !rawNext.startsWith('//') ? rawNext : '/'; + const next = rawNext ?? '/'; + const safeNext = next.startsWith('/') && !next.startsWith('//') ? next : '/'; return c.redirect(safeNext, 302); +} + +credentialRoutes.get('/exchange-token', async (c) => + handleExchangeToken(c, c.req.query('token'), c.req.query('next'), false) +); + +credentialRoutes.post('/exchange-token', async (c) => { + const body = await c.req.parseBody(); + const token = typeof body.token === 'string' ? body.token : undefined; + const next = typeof body.next === 'string' ? body.next : undefined; + return handleExchangeToken(c, token, next, true); +}); + +/** + * Bootstrap page for the Owletto extension's side-panel iframe. + * + * The extension mounts the iframe at this route with the deep-link token in the + * URL **fragment** (`#token=…&worker=…`) — fragments are never sent to a server + * and the page strips it from history immediately, so the long-lived token + * never appears in a request URL, server log, or browser history entry. The + * page then POSTs the token to /api/exchange-token (same-origin, so the + * Set-Cookie is honoured) which sets the CHIPS Partitioned session cookie in + * THIS iframe's partition, then redirects the iframe to the app. This is the + * only place the partitioned cookie can be installed, because the partition key + * must be the chrome-extension:// top-level — a separate top-level tab would + * write it to the wrong partition. + */ +credentialRoutes.get('/extension-bootstrap', (c) => { + c.header('Referrer-Policy', 'no-referrer'); + c.header('Cache-Control', 'no-store'); + // Inline script is allowed: the app's CSP sets only frame-ancestors, no + // script-src. The page carries no markup an injection could target. + return c.html( + `Connecting…` + ); }); /** From d391358636e54964e9a3bce3f42b507c72f3f5ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Tue, 26 May 2026 23:10:21 +0100 Subject: [PATCH 2/2] fix(auth): resolve OAuth device-code access tokens in exchange-token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveDeepLinkToken only handled owl_pat_ PATs and Better Auth session tokens. The Owletto extension's CLOUD pairing (OAuth device-code) stores an oauth_tokens access token, which is neither — so POST /api/exchange-token 401'd and the cloud-paired side-panel iframe rendered signed-out (the original bug, for the OAuth path specifically; local-init/native use owl_pat_ and already worked). Resolve via OAuthProvider.verifyAccessToken, which already accepts both PATs and oauth_tokens access tokens, then fall back to the Better Auth session lookup. Test: a genuine OAuth access token (createTestAccessToken, asserted not owl_pat_) now exchanges to a 302 + partitioned cookie. --- .../__tests__/exchange-token-cookie.test.ts | 20 +++++++++++++ packages/server/src/auth/routes.ts | 30 +++++++++++-------- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/packages/server/src/auth/__tests__/exchange-token-cookie.test.ts b/packages/server/src/auth/__tests__/exchange-token-cookie.test.ts index 560e9260e..652413ecc 100644 --- a/packages/server/src/auth/__tests__/exchange-token-cookie.test.ts +++ b/packages/server/src/auth/__tests__/exchange-token-cookie.test.ts @@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it } from 'vitest'; import { cleanupTestDatabase } from '../../__tests__/setup/test-db'; import { addUserToOrganization, + createTestAccessToken, + createTestOAuthClient, createTestOrganization, createTestPAT, createTestUser, @@ -38,6 +40,24 @@ describe('exchange-token cookie posture', () => { expect(cookie).toMatch(/Partitioned/i); }); + it('POST resolves an OAuth device-code access token (cloud pairing path)', async () => { + // The extension's cloud (OAuth device-code) pairing stores an oauth_tokens + // access token, NOT an owl_pat_ PAT — exchange-token must resolve it or the + // cloud iframe 401s and renders signed-out. + const org = await createTestOrganization({ slug: 'xt-oauth-org' }); + const user = await createTestUser({ email: 'xt-oauth@test.example.com' }); + await addUserToOrganization(user.id, org.id, 'owner'); + const client = await createTestOAuthClient(); + const { token } = await createTestAccessToken(user.id, org.id, client.client_id, { + scope: 'profile:read', + }); + expect(token.startsWith('owl_pat_')).toBe(false); // genuinely an OAuth access token + + const res = await postForm('/api/exchange-token', { token, next: '/' }); + expect(res.status).toBe(302); + expect(res.headers.get('set-cookie') ?? '').toMatch(/Partitioned/i); + }); + it('GET keeps a first-party Lax cookie — never Partitioned/None (CLI/menu-bar)', async () => { const token = await patForNewUser('xt-get-org', 'xt-get@test.example.com'); const res = await get(`/api/exchange-token?token=${encodeURIComponent(token)}&next=/`); diff --git a/packages/server/src/auth/routes.ts b/packages/server/src/auth/routes.ts index ae826ef99..f344f1e30 100644 --- a/packages/server/src/auth/routes.ts +++ b/packages/server/src/auth/routes.ts @@ -16,6 +16,7 @@ import { resolveBaseUrl } from './base-url'; import { createAuth } from './index'; import { mcpAuth, requireAuth } from './middleware'; import { OAuthClientsStore } from './oauth/clients'; +import { OAuthProvider } from './oauth/provider'; import { PersonalAccessTokenService } from './tokens'; const credentialRoutes = new Hono<{ Bindings: Env }>(); @@ -346,25 +347,28 @@ async function mintSessionCookieValue( } /** - * Resolve a `?token=…` deep-link credential to a user id. + * Resolve a `?token=…` deep-link credential to a user id. Accepts all three + * credential shapes the deep-link callers hold: + * - Personal Access Tokens (`owl_pat_*`, `personal_access_tokens`) + * - OAuth 2.1 access tokens (`oauth_tokens`) — what the Owletto extension's + * device-code pairing issues; without this the cloud-paired iframe 401s at + * /api/exchange-token and renders signed-out + * - Better Auth session tokens (`session`) — the macOS menu bar / local-init + * hold one and deep-link into the SPA without ever issuing a PAT * - * Accepts either a Personal Access Token (`owl_pat_*`, validated against - * `personal_access_tokens`) or a Better Auth session token (looked up in - * `session`). The session-token path lets the macOS menu bar — which holds - * a session token from POST /api/local-init — deep-link the user into - * the SPA without ever issuing a PAT. + * OAuthProvider.verifyAccessToken already covers the first two; the session + * lookup is the fallback for the third. */ async function resolveDeepLinkToken( c: Context<{ Bindings: Env }>, token: string ): Promise { - if (token.startsWith('owl_pat_')) { - const sql = createDbClientFromEnv(c.env); - const authInfo = await new PersonalAccessTokenService(sql).verify(token); - return authInfo?.userId ?? null; - } - // Treat anything else as a session token. Better Auth's adapter looks it up - // by the raw token (the unsigned half of the cookie value). + const sql = createDbClientFromEnv(c.env); + const baseUrl = resolveBaseUrl({ request: c.req.raw }); + const authInfo = await new OAuthProvider(sql, baseUrl).verifyAccessToken(token); + if (authInfo?.userId) return authInfo.userId; + // Otherwise treat it as a Better Auth session token. Better Auth's adapter + // looks it up by the raw token (the unsigned half of the cookie value). const auth = await createAuth(c.env, c.req.raw); const ctx = await auth.$context; const session = await ctx.internalAdapter.findSession(token);