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
30 changes: 30 additions & 0 deletions packages/server/src/__tests__/setup/test-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,36 @@ export const post = (path: string, options?: Parameters<typeof testRequest>[2])
export const del = (path: string, options?: Omit<Parameters<typeof testRequest>[2], 'body'>) =>
testRequest('DELETE', path, options);

/**
* POST an `application/x-www-form-urlencoded` body — what a browser <form>
* 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<string, string>,
options?: { cookie?: string; env?: Partial<Env> }
): Promise<TestResponse> {
await ensureWorkspaceProvider();
const headers: Record<string, string> = {
'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
// ============================================
Expand Down
86 changes: 86 additions & 0 deletions packages/server/src/auth/__tests__/exchange-token-cookie.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { cleanupTestDatabase } from '../../__tests__/setup/test-db';
import {
addUserToOrganization,
createTestAccessToken,
createTestOAuthClient,
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<string> {
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('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=/`);

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');
});
});
158 changes: 132 additions & 26 deletions packages/server/src/auth/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>();
Expand Down Expand Up @@ -280,11 +281,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://<id> 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' };
Expand All @@ -300,39 +323,52 @@ 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 };
}

/**
* 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<string | null> {
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;
Comment on lines +366 to +369
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Don't let arbitrary OAuth bearers mint full web sessions.

This path keeps only userId from verifyAccessToken() and then creates a 7-day Better Auth session, so a scoped OAuth access token can be upgraded into an unrestricted browser login. The new test coverage even proves a generic client token with only profile:read is accepted. Please restrict this exchange to the Owletto pairing client, a dedicated scope, or a single-use exchange credential instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/server/src/auth/routes.ts` around lines 366 - 369, The current flow
returns authInfo.userId for any valid OAuth token which lets arbitrary bearer
tokens mint full 7-day web sessions; modify the logic around
OAuthProvider.verifyAccessToken to only allow session creation for an approved
exchange token by checking a unique identifier or scope on authInfo (e.g.
authInfo.clientId === ALLOWED_PAIRING_CLIENT_ID OR authInfo.scopes includes
'session:exchange' or authInfo.singleUse === true) before returning the userId;
otherwise reject/throw and do not create a session. Ensure the checks are
applied where verifyAccessToken(...) is called and keep the existing calls to
createDbClientFromEnv and resolveBaseUrl untouched.

// 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);
Expand All @@ -347,27 +383,41 @@ 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' },
401
);
}

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 },
Expand All @@ -376,9 +426,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(
`<!doctype html><meta charset="utf-8"><title>Connecting…</title><body><script>
(function () {
var h = new URLSearchParams(location.hash.slice(1));
var token = h.get("token") || "";
var worker = h.get("worker") || "";
// Strip the token out of the URL before doing anything else.
history.replaceState(null, "", location.pathname);
if (!token) { location.replace("/"); return; }
var form = document.createElement("form");
form.method = "POST";
form.action = "/api/exchange-token";
function add(name, value) {
var i = document.createElement("input");
i.type = "hidden"; i.name = name; i.value = value;
form.appendChild(i);
}
add("token", token);
add("next", worker ? "/#worker=" + encodeURIComponent(worker) : "/");
document.body.appendChild(form);
form.submit();
})();
</script></body>`
);
});

/**
Expand Down
Loading