From b7523e27909720ed0ffe96ebd0b39a2567ef441f Mon Sep 17 00:00:00 2001
From: nhyiramante1
Date: Thu, 30 Jul 2026 23:27:30 -0400
Subject: [PATCH 1/4] feat: prototype room-scoped OAuth PKCE launch
---
backend/package-lock.json | 18 ++
backend/package.json | 1 +
backend/src/__tests__/db.test.ts | 6 +-
backend/src/__tests__/rooms.test.ts | 51 ++++++
backend/src/app.ts | 105 ++++++++++-
backend/src/auth.ts | 33 ++++
backend/src/db.ts | 26 +++
backend/src/index.ts | 2 +
backend/src/rooms.ts | 118 ++++++++++++
backend/src/routes/oauth-pages.ts | 139 ++++++++++++++
docs/oauth-rooms-pkce-poc.md | 73 ++++++++
frontend/src/api/rooms.ts | 30 ++++
.../tools/__tests__/tools-config.test.ts | 16 +-
frontend/src/pages/tools/index.tsx | 16 +-
prototype-mindmap/src/PlatformBootstrap.tsx | 44 ++++-
prototype-mindmap/src/platform-session.ts | 169 +++++++++++++++++-
16 files changed, 822 insertions(+), 25 deletions(-)
create mode 100644 backend/src/__tests__/rooms.test.ts
create mode 100644 backend/src/rooms.ts
create mode 100644 backend/src/routes/oauth-pages.ts
create mode 100644 docs/oauth-rooms-pkce-poc.md
create mode 100644 frontend/src/api/rooms.ts
diff --git a/backend/package-lock.json b/backend/package-lock.json
index 84135932..1bf60817 100644
--- a/backend/package-lock.json
+++ b/backend/package-lock.json
@@ -8,6 +8,7 @@
"name": "writing-tools-backend",
"version": "1.0.0",
"dependencies": {
+ "@better-auth/oauth-provider": "^1.6.22",
"@hono/node-server": "^1.13.7",
"better-auth": "1.6.22",
"better-sqlite3": "^12.0.0",
@@ -110,6 +111,23 @@
}
}
},
+ "node_modules/@better-auth/oauth-provider": {
+ "version": "1.6.22",
+ "resolved": "https://registry.npmjs.org/@better-auth/oauth-provider/-/oauth-provider-1.6.22.tgz",
+ "integrity": "sha512-xXFoRqP6pUFbYsgC3eTJ1d+DijcKJVJnq1KAMlaocRM2UNwRlXwrV4zI+GMGCAgBYHrcEhoxxn17IYUGXy6dOw==",
+ "license": "MIT",
+ "dependencies": {
+ "jose": "^6.1.3",
+ "zod": "^4.3.6"
+ },
+ "peerDependencies": {
+ "@better-auth/core": "^1.6.22",
+ "@better-auth/utils": "0.4.2",
+ "@better-fetch/fetch": "1.3.1",
+ "better-auth": "^1.6.22",
+ "better-call": "1.3.7"
+ }
+ },
"node_modules/@better-auth/prisma-adapter": {
"version": "1.6.22",
"resolved": "https://registry.npmjs.org/@better-auth/prisma-adapter/-/prisma-adapter-1.6.22.tgz",
diff --git a/backend/package.json b/backend/package.json
index 780ae0fa..eb4e2b41 100644
--- a/backend/package.json
+++ b/backend/package.json
@@ -13,6 +13,7 @@
"test:watch": "vitest"
},
"dependencies": {
+ "@better-auth/oauth-provider": "^1.6.22",
"@hono/node-server": "^1.13.7",
"better-auth": "1.6.22",
"better-sqlite3": "^12.0.0",
diff --git a/backend/src/__tests__/db.test.ts b/backend/src/__tests__/db.test.ts
index 5493ec99..6cbee105 100644
--- a/backend/src/__tests__/db.test.ts
+++ b/backend/src/__tests__/db.test.ts
@@ -22,7 +22,7 @@ afterEach(() => {
describe('migrations', () => {
it('creates our schema and records the version', () => {
const conn = db();
- expect(conn.pragma('user_version', { simple: true })).toBe(3);
+ expect(conn.pragma('user_version', { simple: true })).toBe(4);
// The table is usable, not merely declared.
const table = conn
@@ -58,7 +58,7 @@ describe('migrations', () => {
// Reopening must not drop or recreate the table (CREATE TABLE would throw).
const conn = db();
- expect(conn.pragma('user_version', { simple: true })).toBe(3);
+ expect(conn.pragma('user_version', { simple: true })).toBe(4);
const rows = conn.prepare(`SELECT COUNT(*) AS n FROM llm_usage`).get() as {
n: number;
};
@@ -87,7 +87,7 @@ describe('legacy auth.db rename', () => {
expect(user).toEqual({ email: 'a@b.c' });
// ...and our migrations then run on top of the adopted database.
- expect(conn.pragma('user_version', { simple: true })).toBe(3);
+ expect(conn.pragma('user_version', { simple: true })).toBe(4);
});
it('leaves an existing app.db alone when a stray auth.db is also present', async () => {
diff --git a/backend/src/__tests__/rooms.test.ts b/backend/src/__tests__/rooms.test.ts
new file mode 100644
index 00000000..fc3da7cf
--- /dev/null
+++ b/backend/src/__tests__/rooms.test.ts
@@ -0,0 +1,51 @@
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { closeDb } from '../db.js';
+import {
+ createRoom,
+ getRoomForUser,
+ listRooms,
+ selectRoomForOAuth,
+ selectedRoomForOAuth,
+} from '../rooms.js';
+
+let dir: string;
+
+beforeEach(() => {
+ dir = mkdtempSync(path.join(tmpdir(), 'writing-tools-rooms-'));
+ process.env.DATA_DIR = dir;
+});
+
+afterEach(() => {
+ closeDb();
+ rmSync(dir, { recursive: true, force: true });
+ delete process.env.DATA_DIR;
+});
+
+describe('rooms', () => {
+ it('keeps document snapshots private to their owner', () => {
+ const room = createRoom('owner', 'Draft', {
+ beforeCursor: 'hello',
+ selectedText: ' ',
+ afterCursor: 'world',
+ });
+ expect(listRooms('owner')).toHaveLength(1);
+ expect(getRoomForUser(room.id, 'owner')?.doc.afterCursor).toBe('world');
+ expect(getRoomForUser(room.id, 'other')).toBeNull();
+ });
+
+ it('binds an OAuth session only after checking room ownership', () => {
+ const room = createRoom('owner', 'Draft', {
+ beforeCursor: '',
+ selectedText: 'draft',
+ afterCursor: '',
+ });
+ expect(selectRoomForOAuth('session', 'other', room.id)).toBe(false);
+ expect(selectedRoomForOAuth('session', 'other')).toBeUndefined();
+ expect(selectRoomForOAuth('session', 'owner', room.id)).toBe(true);
+ expect(selectedRoomForOAuth('session', 'owner')).toBe(room.id);
+ expect(selectedRoomForOAuth('session', 'other')).toBeUndefined();
+ });
+});
diff --git a/backend/src/app.ts b/backend/src/app.ts
index ba89c4d6..3b12e32b 100644
--- a/backend/src/app.ts
+++ b/backend/src/app.ts
@@ -1,6 +1,7 @@
import { Hono } from 'hono';
import type { Context } from 'hono';
import { cors } from 'hono/cors';
+import { oauthProviderResourceClient } from '@better-auth/oauth-provider/resource-client';
import type { Auth, SessionUser } from './auth.js'; // type-only import, no runtime cost
import {
CONSENT_LEVELS,
@@ -8,8 +9,9 @@ import {
filterExtraDataForConsent,
isConsentLevel,
} from './consent.js';
-import { deviceClientIds, gitCommit, logSecret } from './config.js';
+import { betterAuthUrl, deviceClientIds, gitCommit, logSecret } from './config.js';
import { eraseLoggedData } from './erasure.js';
+import { db } from './db.js';
import { appendLog, pollLogs, zipLogs } from './logging.js';
import {
attributeRequest,
@@ -31,6 +33,7 @@ import {
} from './toolGrants.js';
import { summarizeUsage } from './usage.js';
import { isUserAllowed } from './userAllowlist.js';
+import { createRoom, getRoomForUser, isRoomDoc } from './rooms.js';
// Mints short-lived ephemeral credentials so a browser can open a WebRTC
// Realtime session without ever seeing the server API key. This is the only
@@ -77,6 +80,9 @@ function logSecretGate(c: Context, provided: string): Response | null {
export function createApp({ auth }: { auth?: Auth } = {}): Hono {
const app = new Hono();
+ const verifyOAuthAccessToken = auth?.options
+ ? oauthProviderResourceClient(auth).getActions().verifyAccessToken
+ : null;
// CORS stays fully permissive for now to preserve existing behaviour.
app.use('*', cors({ exposeHeaders: [PLATFORM_AUTH_ERROR_HEADER] }));
@@ -196,6 +202,50 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono {
if (bearer?.startsWith('wtk_')) {
return resolveToolToken(bearer)?.user ?? null;
}
+ if (bearer && verifyOAuthAccessToken) {
+ try {
+ const claims = await verifyOAuthAccessToken(bearer, {
+ verifyOptions: { audience: betterAuthUrl() },
+ scopes: ['openai:chat'],
+ });
+ if (typeof claims.sub !== 'string') return null;
+ const row = db()
+ .prepare(
+ `SELECT email, loggingConsent, isAnonymous, alwaysAllow
+ FROM user WHERE id = ?`,
+ )
+ .get(claims.sub) as
+ | {
+ email?: string | null;
+ loggingConsent?: unknown;
+ isAnonymous?: number | boolean | null;
+ alwaysAllow?: number | boolean | null;
+ }
+ | undefined;
+ if (!row) return null;
+ const isAnonymous = row.isAnonymous === true || row.isAnonymous === 1;
+ return {
+ id: claims.sub,
+ loggingConsent: isConsentLevel(row.loggingConsent)
+ ? row.loggingConsent
+ : DEFAULT_CONSENT_LEVEL,
+ isAnonymous,
+ isAllowed: isUserAllowed({
+ email: row.email,
+ isAnonymous,
+ alwaysAllow: row.alwaysAllow === true || row.alwaysAllow === 1,
+ }),
+ clientId:
+ typeof claims.azp === 'string'
+ ? claims.azp
+ : typeof claims.client_id === 'string'
+ ? claims.client_id
+ : null,
+ };
+ } catch {
+ return null;
+ }
+ }
if (!auth) return null;
const session = await auth.api.getSession({ headers: c.req.raw.headers });
@@ -226,6 +276,59 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono {
};
}
+ // A room is the durable resource the OAuth authorization points at. The add-in
+ // creates it with its normal user session; the browser app later reads it with
+ // an OAuth token whose signed room_id claim must match the path.
+ app.post('/api/rooms', async (c) => {
+ if (!auth) return c.json({ detail: 'Authentication is disabled.' }, 503);
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
+ if (!session) return c.json({ detail: 'Unauthorized' }, 401);
+ const body = (await c.req.json().catch(() => ({}))) as {
+ name?: unknown;
+ doc?: unknown;
+ };
+ if (!isRoomDoc(body.doc)) {
+ return c.json({ detail: 'A valid document snapshot is required.' }, 400);
+ }
+ const fallbackName = body.doc.documentLabel?.trim() || 'Untitled document';
+ const name =
+ typeof body.name === 'string' && body.name.trim()
+ ? body.name.trim().slice(0, 160)
+ : fallbackName.slice(0, 160);
+ const room = createRoom(session.user.id, name, body.doc);
+ return c.json({ id: room.id, name: room.name, created_at: room.createdAt }, 201);
+ });
+
+ app.get('/api/rooms/:roomId', async (c) => {
+ const token = bearerToken(c);
+ if (!token || !verifyOAuthAccessToken) {
+ return c.json({ detail: 'Unauthorized' }, 401);
+ }
+ try {
+ const claims = await verifyOAuthAccessToken(token, {
+ verifyOptions: { audience: betterAuthUrl() },
+ scopes: ['doc:read'],
+ });
+ const roomId = c.req.param('roomId');
+ if (
+ typeof claims.sub !== 'string' ||
+ claims.room_id !== roomId
+ ) {
+ return c.json({ detail: 'This token does not grant access to that room.' }, 403);
+ }
+ const room = getRoomForUser(roomId, claims.sub);
+ if (!room) return c.json({ detail: 'Room not found.' }, 404);
+ return c.json({
+ id: room.id,
+ name: room.name,
+ doc: room.doc,
+ updated_at: room.updatedAt,
+ });
+ } catch {
+ return c.json({ detail: 'Unauthorized' }, 401);
+ }
+ });
+
async function resolveProxyIdentity(c: Context): Promise {
const bearer = bearerToken(c);
if (bearer?.startsWith('wtk_')) {
diff --git a/backend/src/auth.ts b/backend/src/auth.ts
index b52b0d91..8bf9bddc 100644
--- a/backend/src/auth.ts
+++ b/backend/src/auth.ts
@@ -1,9 +1,11 @@
import { betterAuth } from 'better-auth';
+import { oauthProvider } from '@better-auth/oauth-provider';
import {
anonymous,
bearer,
customSession,
deviceAuthorization,
+ jwt,
} from 'better-auth/plugins';
import {
betterAuthSecret,
@@ -23,6 +25,7 @@ import { db } from './db.js';
import { eraseLoggedData } from './erasure.js';
import { anonymizeUserUsage } from './usage.js';
import { isUserAllowed } from './userAllowlist.js';
+import { selectedRoomForOAuth } from './rooms.js';
// A module-level `auth` singleton (not a factory) so the Better Auth CLI can
// auto-discover it: `npx @better-auth/cli migrate` looks for an exported `auth`
@@ -110,6 +113,36 @@ export const auth = betterAuth({
},
},
plugins: [
+ jwt(),
+ oauthProvider({
+ loginPage: '/api/oauth/login',
+ consentPage: '/api/oauth/consent',
+ scopes: ['openai:chat', 'doc:read'],
+ validAudiences: [betterAuthUrl()],
+ grantTypes: ['authorization_code'],
+ allowDynamicClientRegistration: true,
+ allowUnauthenticatedClientRegistration: true,
+ accessTokenExpiresIn: 60 * 60,
+ postLogin: {
+ page: '/api/oauth/room',
+ shouldRedirect: ({ user, session }) =>
+ !selectedRoomForOAuth(session.id, user.id),
+ consentReferenceId: ({ user, session, scopes }) => {
+ if (!scopes.includes('doc:read')) return undefined;
+ const roomId = selectedRoomForOAuth(session.id, user.id);
+ if (!roomId) throw new Error('A room must be selected for doc:read.');
+ return roomId;
+ },
+ },
+ customAccessTokenClaims: ({ referenceId }) => ({
+ ...(referenceId ? { room_id: referenceId } : {}),
+ }),
+ customTokenResponseFields: ({ verificationValue }) => ({
+ ...(verificationValue?.referenceId
+ ? { room_id: verificationValue.referenceId }
+ : {}),
+ }),
+ }),
bearer(),
// Demo mode. signIn.anonymous() mints a real user + session, so the whole
// identity-keyed stack (resolveUser, /api/log, consent gating, usage
diff --git a/backend/src/db.ts b/backend/src/db.ts
index 3dc4a73c..081d370f 100644
--- a/backend/src/db.ts
+++ b/backend/src/db.ts
@@ -118,6 +118,32 @@ const MIGRATIONS: Array<(conn: Database.Database) => void> = [
CREATE INDEX tool_grant_user ON tool_grant (json_extract(user_snapshot, '$.id'));
`);
},
+ // v4 — durable document rooms plus the short-lived room choice made during an
+ // OAuth authorization. OAuth Provider stores the chosen room id as referenceId
+ // on the consent, authorization code and access token; these tables hold the
+ // application resource and the server-side selection that feeds that hook.
+ (conn) => {
+ conn.exec(`
+ CREATE TABLE room (
+ id TEXT PRIMARY KEY,
+ owner_user_id TEXT NOT NULL,
+ name TEXT NOT NULL,
+ doc_snapshot TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL
+ );
+ CREATE INDEX room_owner_updated ON room (owner_user_id, updated_at DESC);
+
+ CREATE TABLE oauth_room_selection (
+ session_id TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL,
+ room_id TEXT NOT NULL,
+ expires_at INTEGER NOT NULL,
+ FOREIGN KEY (room_id) REFERENCES room(id) ON DELETE CASCADE
+ );
+ CREATE INDEX oauth_room_selection_expiry ON oauth_room_selection (expires_at);
+ `);
+ },
];
function migrate(conn: Database.Database): void {
diff --git a/backend/src/index.ts b/backend/src/index.ts
index 02b19cb6..ae9ba15f 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -50,6 +50,8 @@ const app = createApp({ auth });
if (auth) {
const { devicePageHandler } = await import('./routes/device-approval.js');
app.get('/api/device', devicePageHandler);
+ const { registerOAuthPages } = await import('./routes/oauth-pages.js');
+ registerOAuthPages(app, auth);
}
// Debug UI — only when auth is enabled AND DEBUG=true. Registered here (not in
diff --git a/backend/src/rooms.ts b/backend/src/rooms.ts
new file mode 100644
index 00000000..b50ce191
--- /dev/null
+++ b/backend/src/rooms.ts
@@ -0,0 +1,118 @@
+import { randomBytes } from 'node:crypto';
+import { db } from './db.js';
+
+export interface RoomDoc {
+ documentLabel?: string;
+ beforeCursor: string;
+ selectedText: string;
+ afterCursor: string;
+ contextData?: unknown;
+}
+
+export interface Room {
+ id: string;
+ name: string;
+ doc: RoomDoc;
+ createdAt: number;
+ updatedAt: number;
+}
+
+interface RoomRow {
+ id: string;
+ name: string;
+ doc_snapshot: string;
+ created_at: number;
+ updated_at: number;
+}
+
+const ROOM_SELECTION_TTL_MS = 10 * 60 * 1000;
+
+function parseRoom(row: RoomRow): Room {
+ return {
+ id: row.id,
+ name: row.name,
+ doc: JSON.parse(row.doc_snapshot) as RoomDoc,
+ createdAt: row.created_at,
+ updatedAt: row.updated_at,
+ };
+}
+
+export function isRoomDoc(value: unknown): value is RoomDoc {
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
+ const doc = value as Record;
+ return (
+ typeof doc.beforeCursor === 'string' &&
+ typeof doc.selectedText === 'string' &&
+ typeof doc.afterCursor === 'string' &&
+ (doc.documentLabel === undefined || typeof doc.documentLabel === 'string')
+ );
+}
+
+export function createRoom(userId: string, name: string, doc: RoomDoc): Room {
+ const id = `room_${randomBytes(18).toString('base64url')}`;
+ const now = Date.now();
+ db()
+ .prepare(
+ `INSERT INTO room
+ (id, owner_user_id, name, doc_snapshot, created_at, updated_at)
+ VALUES (?, ?, ?, ?, ?, ?)`,
+ )
+ .run(id, userId, name, JSON.stringify(doc), now, now);
+ return { id, name, doc, createdAt: now, updatedAt: now };
+}
+
+export function listRooms(userId: string): Room[] {
+ const rows = db()
+ .prepare(
+ `SELECT id, name, doc_snapshot, created_at, updated_at
+ FROM room WHERE owner_user_id = ? ORDER BY updated_at DESC`,
+ )
+ .all(userId) as RoomRow[];
+ return rows.map(parseRoom);
+}
+
+export function getRoomForUser(roomId: string, userId: string): Room | null {
+ const row = db()
+ .prepare(
+ `SELECT id, name, doc_snapshot, created_at, updated_at
+ FROM room WHERE id = ? AND owner_user_id = ?`,
+ )
+ .get(roomId, userId) as RoomRow | undefined;
+ return row ? parseRoom(row) : null;
+}
+
+export function selectRoomForOAuth(
+ sessionId: string,
+ userId: string,
+ roomId: string,
+): boolean {
+ if (!getRoomForUser(roomId, userId)) return false;
+ const now = Date.now();
+ db()
+ .prepare(`DELETE FROM oauth_room_selection WHERE expires_at <= ?`)
+ .run(now);
+ db()
+ .prepare(
+ `INSERT INTO oauth_room_selection (session_id, user_id, room_id, expires_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(session_id) DO UPDATE SET
+ user_id = excluded.user_id,
+ room_id = excluded.room_id,
+ expires_at = excluded.expires_at`,
+ )
+ .run(sessionId, userId, roomId, now + ROOM_SELECTION_TTL_MS);
+ return true;
+}
+
+export function selectedRoomForOAuth(
+ sessionId: string,
+ userId: string,
+): string | undefined {
+ const row = db()
+ .prepare(
+ `SELECT room_id FROM oauth_room_selection
+ WHERE session_id = ? AND user_id = ? AND expires_at > ?`,
+ )
+ .get(sessionId, userId, Date.now()) as { room_id: string } | undefined;
+ return row?.room_id;
+}
diff --git a/backend/src/routes/oauth-pages.ts b/backend/src/routes/oauth-pages.ts
new file mode 100644
index 00000000..33041d4f
--- /dev/null
+++ b/backend/src/routes/oauth-pages.ts
@@ -0,0 +1,139 @@
+import type { Context } from 'hono';
+import type { Auth } from '../auth.js';
+import {
+ getRoomForUser,
+ listRooms,
+ selectRoomForOAuth,
+ selectedRoomForOAuth,
+} from '../rooms.js';
+
+const PAGE_STYLE = `
+body{font:16px/1.5 system-ui,sans-serif;max-width:620px;margin:3rem auto;padding:0 1.5rem;color:#172033}
+h1{font-size:1.5rem}button{font:inherit;padding:.65rem 1rem;border:1px solid #aab2c0;border-radius:7px;cursor:pointer}
+.primary{background:#3157d5;color:white;border-color:#3157d5}.room{display:block;width:100%;text-align:left;margin:.7rem 0}
+.muted{color:#657087}.error{color:#b42318}.actions{display:flex;gap:.7rem;margin-top:1.5rem}`;
+
+function shell(title: string, script: string): string {
+ return `${title}${title}
Loading…
`;
+}
+
+const helpers = `
+const app=document.getElementById('app');
+function el(tag,text,cls){const node=document.createElement(tag);if(text!==undefined)node.textContent=text;if(cls)node.className=cls;return node}
+function show(...nodes){app.replaceChildren(...nodes)}
+async function json(url,init){const response=await fetch(url,{credentials:'include',...init});const body=await response.json().catch(()=>({}));if(!response.ok)throw new Error(body.detail||body.error_description||body.error||('Request failed ('+response.status+')'));return body}
+function go(result){const url=result.url||result.redirect_uri;if(url)location.href=url;else throw new Error('Authorization server returned no redirect URL')}
+`;
+
+const LOGIN_HTML = shell(
+ 'Connect Mindmap',
+ `${helpers}
+async function start(){
+ const session=await json('/api/auth/get-session').catch(()=>null);
+ if(session?.user){
+ location.replace('/api/auth/oauth2/authorize'+location.search);
+ return;
+ }
+ const p=el('p','Sign in to choose which Writing Tools room Mindmap may open.');
+ const b=el('button','Sign in with Google','primary');
+ b.onclick=async()=>{try{
+ const callbackURL=location.href;
+ const result=await json('/api/auth/sign-in/social',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({provider:'google',callbackURL,errorCallbackURL:callbackURL})});
+ location.href=result.url;
+ }catch(error){show(el('p',String(error),'error'))}};
+ show(p,b);
+}
+start();`,
+);
+
+const ROOM_HTML = shell(
+ 'Choose a room',
+ `${helpers}
+async function start(){
+ try{
+ const rooms=await json('/api/oauth/rooms');
+ const params=new URLSearchParams(location.search);
+ const hint=(params.get('state')||'').split('.')[0];
+ const intro=el('p','Mindmap will receive access only to the room you choose.','muted');
+ const nodes=[intro];
+ for(const room of rooms){
+ const b=el('button',room.name+(room.id===hint?' — from this launch':''),'room'+(room.id===hint?' primary':''));
+ b.onclick=()=>choose(room.id);
+ nodes.push(b);
+ }
+ if(!rooms.length)nodes.push(el('p','No rooms are available for this account. Return to Writing Tools and launch Mindmap again.','error'));
+ show(...nodes);
+ }catch(error){show(el('p',String(error),'error'))}
+}
+async function choose(roomId){
+ try{
+ await json('/api/oauth/room/select',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({room_id:roomId})});
+ const result=await json('/api/auth/oauth2/continue',{method:'POST',headers:{'Content-Type':'application/json','Accept':'application/json'},body:JSON.stringify({postLogin:true,oauth_query:location.search.slice(1)})});
+ go(result);
+ }catch(error){show(el('p',String(error),'error'))}
+}
+start();`,
+);
+
+const CONSENT_HTML = shell(
+ 'Allow Mindmap access?',
+ `${helpers}
+async function start(){
+ try{
+ const room=await json('/api/oauth/selected-room');
+ const body=el('p','Mindmap wants to read “'+room.name+'” and use Writing Tools AI on your behalf.');
+ const note=el('p','The access token is limited to this room and expires in one hour.','muted');
+ const actions=el('div',undefined,'actions');
+ const allow=el('button','Allow','primary');allow.onclick=()=>decide(true);
+ const deny=el('button','Deny');deny.onclick=()=>decide(false);
+ actions.append(allow,deny);show(body,note,actions);
+ }catch(error){show(el('p',String(error),'error'))}
+}
+async function decide(accept){
+ try{
+ const result=await json('/api/auth/oauth2/consent',{method:'POST',headers:{'Content-Type':'application/json','Accept':'application/json'},body:JSON.stringify({accept,oauth_query:location.search.slice(1)})});
+ go(result);
+ }catch(error){show(el('p',String(error),'error'))}
+}
+start();`,
+);
+
+export function registerOAuthPages(app: import('hono').Hono, auth: Auth): void {
+ app.get('/api/oauth/login', (c) => c.html(LOGIN_HTML));
+ app.get('/api/oauth/room', (c) => c.html(ROOM_HTML));
+ app.get('/api/oauth/consent', (c) => c.html(CONSENT_HTML));
+
+ app.get('/api/oauth/rooms', async (c) => {
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
+ if (!session) return c.json({ detail: 'Unauthorized' }, 401);
+ return c.json(
+ listRooms(session.user.id).map(({ id, name, updatedAt }) => ({
+ id,
+ name,
+ updated_at: updatedAt,
+ })),
+ );
+ });
+
+ app.post('/api/oauth/room/select', async (c) => {
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
+ if (!session) return c.json({ detail: 'Unauthorized' }, 401);
+ const body = (await c.req.json().catch(() => ({}))) as { room_id?: unknown };
+ if (
+ typeof body.room_id !== 'string' ||
+ !selectRoomForOAuth(session.session.id, session.user.id, body.room_id)
+ ) {
+ return c.json({ detail: 'Room not found for this account.' }, 404);
+ }
+ return c.json({ ok: true });
+ });
+
+ app.get('/api/oauth/selected-room', async (c) => {
+ const session = await auth.api.getSession({ headers: c.req.raw.headers });
+ if (!session) return c.json({ detail: 'Unauthorized' }, 401);
+ const roomId = selectedRoomForOAuth(session.session.id, session.user.id);
+ const room = roomId ? getRoomForUser(roomId, session.user.id) : null;
+ if (!room) return c.json({ detail: 'Choose a room first.' }, 409);
+ return c.json({ id: room.id, name: room.name });
+ });
+}
diff --git a/docs/oauth-rooms-pkce-poc.md b/docs/oauth-rooms-pkce-poc.md
new file mode 100644
index 00000000..4412598e
--- /dev/null
+++ b/docs/oauth-rooms-pkce-poc.md
@@ -0,0 +1,73 @@
+# Rooms + OAuth Authorization Code/PKCE proof of concept
+
+This branch replaces the launcher grant for Mindmap with a provider-native OAuth
+flow. It deliberately leaves the existing `/api/handoff` implementation in place
+for comparison and for other tools.
+
+## Mental model
+
+There are three different objects:
+
+1. **Room** — durable server-side resource containing a document snapshot. The
+ add-in creates it while authenticated as the writer.
+2. **Authorization code** — short-lived, single-use result of the writer approving
+ Mindmap. It is useless without the PKCE verifier that Mindmap generated and kept
+ in its own session storage.
+3. **Access token** — one-hour credential issued after the code + verifier exchange.
+ Its signed `room_id` claim limits it to one room; scopes independently limit the
+ operations (`doc:read`, `openai:chat`).
+
+The room id is not a secret. It may appear in the launch URL and OAuth request.
+Possession of it grants nothing.
+
+## End-to-end sequence
+
+1. The taskpane reads the current `DocContext` and sends it to `POST /api/rooms`
+ with its existing Better Auth session bearer.
+2. The taskpane opens `https://mindmap…/?room=room_…`.
+3. Mindmap registers as an OAuth public browser client (cached in local storage),
+ creates a random PKCE verifier, stores the verifier in session storage, and sends
+ only its SHA-256 challenge to `/api/auth/oauth2/authorize`. The non-secret room
+ hint is included in the standard `state` value so the picker can highlight it;
+ the complete state also contains random bytes and is matched exactly at callback.
+4. The authorization server authenticates the writer in the system browser. This
+ may require Google sign-in because the Office taskpane and system browser do not
+ necessarily share cookies.
+5. Better Auth's `postLogin` hook sends the writer to the room picker. The backend
+ accepts a choice only when that signed-in user owns the room.
+6. `consentReferenceId` returns the selected room id. The OAuth Provider plugin
+ carries it through consent, authorization code, and access token.
+7. Mindmap receives the code at its registered redirect URI and exchanges it with
+ the locally retained verifier. The verifier never travels in the launcher URL.
+8. Mindmap calls `GET /api/rooms/:roomId`. The resource server verifies the token's
+ signature, issuer, audience, `doc:read` scope, subject, and exact `room_id`.
+
+## Relationship to #579 and #593
+
+- This does not need `wt_api`, and the backend URL comes from Mindmap's deploy-time
+ `VITE_BACKEND_URL`. That removes the attacker-selected proxy problem identified
+ on #579 for this flow.
+- Origin checks may remain defense-in-depth for the old handoff flow; they are not
+ treated as client authentication here.
+- #593's Pages deployment is still useful and should supply the production backend
+ URL. Its skipped `wt_api` smoke assertion should be replaced for this branch with
+ an OAuth configuration/launch smoke test.
+- The clean implementation branch is based on current `origin/main`, not the dirty
+ or ahead Mindmap worktrees. The useful #579/#593 changes should be rebased or
+ cherry-picked after review rather than making this proof of concept depend on
+ their current branch topology.
+
+## Deliberate proof-of-concept limits
+
+- A room currently has one owner and one document snapshot. There is no membership
+ table, live synchronization, document update endpoint, or multi-document room.
+- Public dynamic client registration is enabled to keep the branch runnable without
+ an out-of-band client provisioning step. Production should provision the known
+ Mindmap client (or tightly rate-limit/validate registration) and disable open
+ registration.
+- Tokens expire after one hour; refresh tokens are not enabled.
+- The room picker's pending selection is session-scoped and expires after ten
+ minutes.
+- This is bearer-token security. PKCE prevents interception of the authorization
+ code; it does not make a stolen access token unusable. Sender-constrained tokens
+ (for example DPoP) would be a separate layer.
diff --git a/frontend/src/api/rooms.ts b/frontend/src/api/rooms.ts
new file mode 100644
index 00000000..43759e0f
--- /dev/null
+++ b/frontend/src/api/rooms.ts
@@ -0,0 +1,30 @@
+import { SERVER_URL } from './index';
+
+export async function createRoom(
+ token: string,
+ doc: DocContext,
+): Promise<{ id: string; name: string }> {
+ const res = await fetch(`${SERVER_URL}/rooms`, {
+ method: 'POST',
+ credentials: 'omit',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({
+ name: doc.documentLabel,
+ doc,
+ }),
+ });
+ if (!res.ok) {
+ const body = (await res.json().catch(() => ({}))) as { detail?: string };
+ throw new Error(body.detail ?? `Room creation failed (${res.status})`);
+ }
+ return (await res.json()) as { id: string; name: string };
+}
+
+export function withRoomHint(url: string, roomId: string): string {
+ const target = new URL(url);
+ target.searchParams.set('room', roomId);
+ return target.toString();
+}
diff --git a/frontend/src/pages/tools/__tests__/tools-config.test.ts b/frontend/src/pages/tools/__tests__/tools-config.test.ts
index 26cb4059..81209cd6 100644
--- a/frontend/src/pages/tools/__tests__/tools-config.test.ts
+++ b/frontend/src/pages/tools/__tests__/tools-config.test.ts
@@ -43,12 +43,12 @@ describe('mindmap tool registration', () => {
it('does not access the document or backend when the popup is blocked', async () => {
const getAccessToken = vi.fn();
const getDocContext = vi.fn();
- const createGrant = vi.fn();
+ const createRoom = vi.fn();
await expect(
launchFirstPartyTool(mindmap, {
getAccessToken,
getDocContext,
- createGrant,
+ createRoom,
reserveLaunch: () => null,
completeLaunch: vi.fn(),
cancelLaunch: vi.fn(),
@@ -56,7 +56,7 @@ describe('mindmap tool registration', () => {
).rejects.toThrow('blocked');
expect(getAccessToken).not.toHaveBeenCalled();
expect(getDocContext).not.toHaveBeenCalled();
- expect(createGrant).not.toHaveBeenCalled();
+ expect(createRoom).not.toHaveBeenCalled();
});
it('reserves before reading a document and completes a granted launch', async () => {
@@ -74,9 +74,9 @@ describe('mindmap tool registration', () => {
afterCursor: '',
});
},
- createGrant: () => {
- calls.push('grant');
- return Promise.resolve({ grantId: 'grant', expiresIn: 120 });
+ createRoom: () => {
+ calls.push('room');
+ return Promise.resolve({ id: 'room_123', name: 'Draft' });
},
reserveLaunch: () => {
calls.push('reserve');
@@ -85,8 +85,8 @@ describe('mindmap tool registration', () => {
completeLaunch: (_reservation, url) => calls.push(`open:${url}`),
cancelLaunch: vi.fn(),
});
- expect(calls.slice(0, 4)).toEqual(['reserve', 'token', 'doc', 'grant']);
- expect(calls[4]).toContain('#wt_grant=grant');
+ expect(calls.slice(0, 4)).toEqual(['reserve', 'token', 'doc', 'room']);
+ expect(calls[4]).toContain('?room=room_123');
expect(result).toEqual({ sharedDoc: true });
});
});
diff --git a/frontend/src/pages/tools/index.tsx b/frontend/src/pages/tools/index.tsx
index e8447514..d5ca3e7b 100644
--- a/frontend/src/pages/tools/index.tsx
+++ b/frontend/src/pages/tools/index.tsx
@@ -14,13 +14,12 @@ import { Button } from 'reshaped';
import {
cancelBrowserLaunch,
completeBrowserLaunch,
- createHandoff,
openInBrowser,
reserveBrowserLaunch,
type BrowserLaunchReservation,
type ToolScope,
- withGrantFragment,
} from '@/api/handoff';
+import { createRoom, withRoomHint } from '@/api/rooms';
import { toolsLog } from '@/api/logging';
import { EditorContext } from '@/contexts/editorContext';
import { useAppAuth } from '@/contexts/appAuthContext';
@@ -89,7 +88,7 @@ export const FIRST_PARTY_TOOLS: FirstPartyTool[] = MINDMAP_TOOL_ENABLED
interface LaunchToolDependencies {
getAccessToken(): Promise;
getDocContext(): Promise;
- createGrant: typeof createHandoff;
+ createRoom: typeof createRoom;
reserveLaunch(): BrowserLaunchReservation | null;
completeLaunch(reservation: BrowserLaunchReservation, url: string): void;
cancelLaunch(reservation: BrowserLaunchReservation): void;
@@ -108,14 +107,11 @@ export async function launchFirstPartyTool(
const doc = tool.scopes.includes('doc:read')
? await dependencies.getDocContext()
: undefined;
- const { grantId } = await dependencies.createGrant(token, {
- toolClientId: tool.id,
- scopes: tool.scopes,
- doc,
- });
+ if (!doc) throw new Error('Mindmap requires a document room.');
+ const room = await dependencies.createRoom(token, doc);
dependencies.completeLaunch(
reservation,
- withGrantFragment(tool.url, grantId),
+ withRoomHint(tool.url, room.id),
);
return { sharedDoc: doc !== undefined };
} catch (error) {
@@ -156,7 +152,7 @@ export default function Tools() {
const result = await launchFirstPartyTool(tool, {
getAccessToken,
getDocContext: () => editorAPI.getDocContext(),
- createGrant: createHandoff,
+ createRoom,
reserveLaunch: reserveBrowserLaunch,
completeLaunch: completeBrowserLaunch,
cancelLaunch: cancelBrowserLaunch,
diff --git a/prototype-mindmap/src/PlatformBootstrap.tsx b/prototype-mindmap/src/PlatformBootstrap.tsx
index 568ef264..8e6ad1fa 100644
--- a/prototype-mindmap/src/PlatformBootstrap.tsx
+++ b/prototype-mindmap/src/PlatformBootstrap.tsx
@@ -11,12 +11,17 @@ import {
} from "./session-persistence";
import {
clearPlatformSession,
+ beginRoomAuthorization,
exchangeGrant,
+ finishRoomAuthorization,
grantFromHash,
GrantExchangeError,
launchRequired,
PLATFORM_SESSION_STORAGE_KEY,
readPlatformSession,
+ roomFromSearch,
+ oauthCallbackFromSearch,
+ scrubOAuthFromUrl,
scrubGrantFromUrl,
snapshotText,
writePlatformSession,
@@ -164,7 +169,9 @@ export class AppErrorBoundary extends Component<{ children: ReactNode }, { faile
export function initialBootState(): BootState {
if (typeof window === "undefined") return { kind: "connecting" };
const hasGrant = Boolean(grantFromHash(window.location.hash));
- if (hasGrant) return { kind: "connecting" };
+ if (hasGrant || roomFromSearch(window.location.search) || oauthCallbackFromSearch(window.location.search)) {
+ return { kind: "connecting" };
+ }
const storage = browserStorage();
if (!storage) return { kind: "blocked", reason: "storage_unavailable" };
const stored = readPlatformSession(storage.session);
@@ -303,9 +310,42 @@ function PlatformBootstrapContent() {
useEffect(() => {
const grant = grantFromHash(window.location.hash);
- if (!grant || exchangeStarted.current) return;
+ if (exchangeStarted.current) return;
+ const room = roomFromSearch(window.location.search);
+ const callback = oauthCallbackFromSearch(window.location.search);
+ if (!grant && !room && !callback) return;
exchangeStarted.current = true;
capturedGrant.current = grant;
+ const storage = browserStorage();
+ if (!storage) {
+ setBoot({ kind: "blocked", reason: "storage_unavailable" });
+ return;
+ }
+ if (callback) {
+ void finishRoomAuthorization(window.location.search, storage.session)
+ .then((session) => {
+ scrubOAuthFromUrl();
+ writePlatformSession(storage.session, session);
+ const saved = savedMindmapSummary(storage.local);
+ if (saved) setBoot({ kind: "choice", session, saved });
+ else commitDecision(session, "start_new");
+ })
+ .catch((error) => {
+ setBoot({
+ kind: "blocked",
+ reason: error instanceof GrantExchangeError && error.code === "network" ? "network" : "grant_invalid",
+ detail: error instanceof Error ? error.message : String(error),
+ });
+ });
+ return;
+ }
+ if (room) {
+ void beginRoomAuthorization(room, storage.session).catch((error) => {
+ setBoot({ kind: "blocked", reason: "network", detail: error instanceof Error ? error.message : String(error) });
+ });
+ return;
+ }
+ if (!grant) return;
try {
scrubGrantFromUrl(window.location, window.history);
} catch {
diff --git a/prototype-mindmap/src/platform-session.ts b/prototype-mindmap/src/platform-session.ts
index 9ca8724b..757a4dfe 100644
--- a/prototype-mindmap/src/platform-session.ts
+++ b/prototype-mindmap/src/platform-session.ts
@@ -2,6 +2,8 @@
export const PLATFORM_SESSION_STORAGE_KEY = "prototype-mindmap-platform-session-v1";
export const PLATFORM_SESSION_VERSION = 1;
+const OAUTH_CLIENT_STORAGE_KEY = "prototype-mindmap-oauth-client-v1";
+const OAUTH_REQUEST_STORAGE_KEY = "prototype-mindmap-oauth-request-v1";
const viteEnv = (import.meta as ImportMeta & { env?: Record }).env;
@@ -142,7 +144,7 @@ export function readPlatformSession(storage: Pick): Platform
if (
parsed.version !== PLATFORM_SESSION_VERSION ||
typeof parsed.accessToken !== "string" ||
- !parsed.accessToken.startsWith("wtk_") ||
+ (!parsed.accessToken.startsWith("wtk_") && parsed.accessToken.length < 16) ||
typeof parsed.expiresAt !== "number" ||
!Number.isFinite(parsed.expiresAt) ||
typeof parsed.capturedAt !== "number" ||
@@ -172,6 +174,171 @@ export function readPlatformSession(storage: Pick): Platform
}
}
+interface OAuthRequestState {
+ state: string;
+ verifier: string;
+ roomId: string;
+ clientId: string;
+ redirectUri: string;
+}
+
+function base64Url(bytes: Uint8Array): string {
+ let binary = "";
+ for (const byte of bytes) binary += String.fromCharCode(byte);
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
+
+async function sha256(value: string): Promise {
+ return base64Url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value))));
+}
+
+export function roomFromSearch(search: string): string | null {
+ const room = new URLSearchParams(search).get("room");
+ return room?.startsWith("room_") ? room : null;
+}
+
+export function oauthCallbackFromSearch(search: string): boolean {
+ const params = new URLSearchParams(search);
+ return params.has("code") || params.has("error");
+}
+
+function oauthBase(backendUrl = PLATFORM_BACKEND_URL): string {
+ return `${backendUrl.replace(/\/$/, "")}/auth/oauth2`;
+}
+
+function oauthResource(backendUrl = PLATFORM_BACKEND_URL): string {
+ return new URL(backendUrl).origin;
+}
+
+function callbackUri(): string {
+ return `${window.location.origin}${window.location.pathname}`;
+}
+
+async function clientId(storage: Storage, backendUrl = PLATFORM_BACKEND_URL): Promise {
+ const redirectUri = callbackUri();
+ try {
+ const saved = JSON.parse(storage.getItem(OAUTH_CLIENT_STORAGE_KEY) ?? "null") as {
+ clientId?: unknown;
+ redirectUri?: unknown;
+ } | null;
+ if (saved?.redirectUri === redirectUri && typeof saved.clientId === "string") return saved.clientId;
+ } catch {
+ // Re-register below.
+ }
+ const response = await fetch(`${oauthBase(backendUrl)}/register`, {
+ method: "POST",
+ credentials: "omit",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ redirect_uris: [redirectUri],
+ client_name: "Writing Tools Mindmap",
+ client_uri: window.location.origin,
+ token_endpoint_auth_method: "none",
+ grant_types: ["authorization_code"],
+ response_types: ["code"],
+ scope: "openai:chat doc:read",
+ type: "user-agent-based",
+ }),
+ });
+ const body = await response.json().catch(() => ({})) as Record;
+ if (!response.ok || typeof body.client_id !== "string") {
+ throw new Error(typeof body.error_description === "string" ? body.error_description : "Mindmap OAuth client registration failed.");
+ }
+ storage.setItem(OAUTH_CLIENT_STORAGE_KEY, JSON.stringify({ clientId: body.client_id, redirectUri }));
+ return body.client_id;
+}
+
+export async function beginRoomAuthorization(
+ roomId: string,
+ storage: Storage,
+ backendUrl = PLATFORM_BACKEND_URL,
+): Promise {
+ const id = await clientId(localStorage, backendUrl);
+ const verifier = base64Url(crypto.getRandomValues(new Uint8Array(48)));
+ // Keep the non-secret room hint inside the standard state parameter. Better Auth
+ // signs/round-trips state but intentionally strips unknown authorize parameters.
+ // The random suffix still makes the complete state value an unpredictable CSRF
+ // binding, and the callback must match it byte-for-byte.
+ const state = `${roomId}.${base64Url(crypto.getRandomValues(new Uint8Array(24)))}`;
+ const redirectUri = callbackUri();
+ const request: OAuthRequestState = { state, verifier, roomId, clientId: id, redirectUri };
+ storage.setItem(OAUTH_REQUEST_STORAGE_KEY, JSON.stringify(request));
+ const authorize = new URL(`${oauthBase(backendUrl)}/authorize`);
+ authorize.searchParams.set("client_id", id);
+ authorize.searchParams.set("redirect_uri", redirectUri);
+ authorize.searchParams.set("response_type", "code");
+ authorize.searchParams.set("scope", "openai:chat doc:read");
+ authorize.searchParams.set("resource", oauthResource(backendUrl));
+ authorize.searchParams.set("state", state);
+ authorize.searchParams.set("code_challenge", await sha256(verifier));
+ authorize.searchParams.set("code_challenge_method", "S256");
+ window.location.assign(authorize.toString());
+}
+
+export async function finishRoomAuthorization(
+ search: string,
+ storage: Storage,
+ options: { backendUrl?: string; now?: () => number } = {},
+): Promise {
+ const params = new URLSearchParams(search);
+ const oauthError = params.get("error");
+ if (oauthError) throw new GrantExchangeError("invalid", params.get("error_description") || oauthError);
+ const code = params.get("code");
+ let request: OAuthRequestState;
+ try {
+ request = JSON.parse(storage.getItem(OAUTH_REQUEST_STORAGE_KEY) ?? "null") as OAuthRequestState;
+ } catch {
+ throw new GrantExchangeError("invalid", "The saved PKCE request is missing.");
+ }
+ if (!request || !code || params.get("state") !== request.state) {
+ throw new GrantExchangeError("invalid", "The OAuth callback did not match this Mindmap launch.");
+ }
+ storage.removeItem(OAUTH_REQUEST_STORAGE_KEY);
+ const backendUrl = options.backendUrl ?? PLATFORM_BACKEND_URL;
+ const tokenResponse = await fetch(`${oauthBase(backendUrl)}/token`, {
+ method: "POST",
+ credentials: "omit",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "authorization_code",
+ client_id: request.clientId,
+ redirect_uri: request.redirectUri,
+ code,
+ code_verifier: request.verifier,
+ resource: oauthResource(backendUrl),
+ }),
+ });
+ const token = await tokenResponse.json().catch(() => ({})) as Record;
+ if (!tokenResponse.ok || typeof token.access_token !== "string") {
+ throw new GrantExchangeError("invalid", typeof token.error_description === "string" ? token.error_description : "Token exchange failed.");
+ }
+ const roomId = typeof token.room_id === "string" && token.room_id.startsWith("room_")
+ ? token.room_id
+ : null;
+ if (!roomId) throw new GrantExchangeError("invalid", "The token was not bound to a room.");
+ const roomResponse = await fetch(`${backendUrl.replace(/\/$/, "")}/rooms/${encodeURIComponent(roomId)}`, {
+ credentials: "omit",
+ headers: { Authorization: `Bearer ${token.access_token}` },
+ });
+ const room = await roomResponse.json().catch(() => ({})) as Record;
+ const doc = validDocContext(room.doc);
+ if (!roomResponse.ok || !doc) throw new GrantExchangeError("invalid", "The authorized room could not be loaded.");
+ const now = (options.now ?? Date.now)();
+ const expiresIn = typeof token.expires_in === "number" ? token.expires_in : 3600;
+ return {
+ version: 1,
+ accessToken: token.access_token,
+ expiresAt: now + expiresIn * 1000,
+ scopes: typeof token.scope === "string" ? token.scope.split(" ") : ["openai:chat", "doc:read"],
+ doc,
+ capturedAt: typeof room.updated_at === "number" ? room.updated_at : now,
+ };
+}
+
+export function scrubOAuthFromUrl(): void {
+ window.history.replaceState(window.history.state, "", window.location.pathname);
+}
+
export function writePlatformSession(
storage: Pick,
session: PlatformSession,
From d3053b8cb9cd823c9c502bd3dd04066c4c250d11 Mon Sep 17 00:00:00 2001
From: nhyiramante1
Date: Fri, 31 Jul 2026 11:02:55 -0400
Subject: [PATCH 2/4] fix: address review on room OAuth PKCE prototype
Review follow-ups on #594:
- Key the room selection to the OAuth authorization state, not just the
browser session, and consume it when the code is exchanged. Concurrent
authorization tabs can no longer overwrite each other's room.
- Verify token.room_id matches the launched room before loading it.
- Scrub OAuth callback parameters synchronously, including failure paths.
- Require wtk_ or compact-JWT shape for stored access tokens.
- Show the verified registered client name and redirect origin on the room
and consent screens instead of a hardcoded "Mindmap".
- Replace the room picker with confirmation of the request-bound room, so
the authorized room is always the launched one.
- Split erasure: activity withdrawal preserves active rooms, account
deletion removes them. eraseAccountData spreads loggedDataOperations() so
it stays a structural superset, and the historical warning explaining why
is restored.
- Make room launching explicit via launchKind rather than assuming every
first-party tool needs a room.
Adds a Better Auth integration test covering signed-query state
propagation, tampering, expiry, and missing-client rejection. That pins the
oauth_query middleware coupling the new endpoints depend on: the body
parameter looks unused but is what triggers signature verification.
Backend 121 tests pass locally.
Authored by Codex; reviewed and committed from Claude Code.
Co-Authored-By: Claude Opus 5
---
backend/src/__tests__/app.test.ts | 3 +
backend/src/__tests__/db.test.ts | 15 +-
backend/src/__tests__/erasure.test.ts | 31 +++-
.../oauth-room-authorization.test.ts | 29 ++++
.../oauth-room-middleware.integration.test.ts | 154 ++++++++++++++++++
.../src/__tests__/oauth-room-resource.test.ts | 67 ++++++++
backend/src/__tests__/rooms.test.ts | 40 ++++-
backend/src/app.ts | 28 +++-
backend/src/auth.ts | 50 ++++--
backend/src/db.ts | 18 ++
backend/src/erasure.ts | 70 ++++----
backend/src/index.ts | 2 +-
backend/src/oauth-room-authorization.ts | 126 ++++++++++++++
backend/src/rooms.ts | 42 ++++-
backend/src/routes/oauth-pages.ts | 101 +++---------
docs/oauth-rooms-pkce-poc.md | 19 ++-
frontend/src/account/AccountPage.tsx | 5 +-
.../tools/__tests__/tools-config.test.ts | 32 ++++
frontend/src/pages/tools/index.tsx | 13 +-
prototype-mindmap/src/PlatformBootstrap.tsx | 7 +-
.../src/platform-session.test.ts | 91 +++++++++++
prototype-mindmap/src/platform-session.ts | 79 ++++++---
22 files changed, 838 insertions(+), 184 deletions(-)
create mode 100644 backend/src/__tests__/oauth-room-authorization.test.ts
create mode 100644 backend/src/__tests__/oauth-room-middleware.integration.test.ts
create mode 100644 backend/src/__tests__/oauth-room-resource.test.ts
create mode 100644 backend/src/oauth-room-authorization.ts
diff --git a/backend/src/__tests__/app.test.ts b/backend/src/__tests__/app.test.ts
index 5dd31b6a..6a279617 100644
--- a/backend/src/__tests__/app.test.ts
+++ b/backend/src/__tests__/app.test.ts
@@ -246,6 +246,9 @@ describe('DELETE /api/me/activity', () => {
const res = await authApp.request('/api/me/activity', { method: 'DELETE' });
expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({
+ message: 'Your logged activity has been erased.',
+ });
await expect(readUserLog('usr-del')).rejects.toThrow();
});
diff --git a/backend/src/__tests__/db.test.ts b/backend/src/__tests__/db.test.ts
index 6cbee105..99242f6e 100644
--- a/backend/src/__tests__/db.test.ts
+++ b/backend/src/__tests__/db.test.ts
@@ -22,7 +22,7 @@ afterEach(() => {
describe('migrations', () => {
it('creates our schema and records the version', () => {
const conn = db();
- expect(conn.pragma('user_version', { simple: true })).toBe(4);
+ expect(conn.pragma('user_version', { simple: true })).toBe(5);
// The table is usable, not merely declared.
const table = conn
@@ -45,6 +45,15 @@ describe('migrations', () => {
)
.get();
expect(grantTable).toBeDefined();
+
+ // v5 isolates room choices by OAuth authorization request.
+ const selectionCols = conn
+ .prepare(`PRAGMA table_info(oauth_room_selection)`)
+ .all() as { name: string; pk: number }[];
+ expect(selectionCols.find((c) => c.name === 'session_id')?.pk).toBe(1);
+ expect(
+ selectionCols.find((c) => c.name === 'authorization_state')?.pk,
+ ).toBe(2);
});
it('is idempotent across reopens — a second open re-runs nothing', () => {
@@ -58,7 +67,7 @@ describe('migrations', () => {
// Reopening must not drop or recreate the table (CREATE TABLE would throw).
const conn = db();
- expect(conn.pragma('user_version', { simple: true })).toBe(4);
+ expect(conn.pragma('user_version', { simple: true })).toBe(5);
const rows = conn.prepare(`SELECT COUNT(*) AS n FROM llm_usage`).get() as {
n: number;
};
@@ -87,7 +96,7 @@ describe('legacy auth.db rename', () => {
expect(user).toEqual({ email: 'a@b.c' });
// ...and our migrations then run on top of the adopted database.
- expect(conn.pragma('user_version', { simple: true })).toBe(4);
+ expect(conn.pragma('user_version', { simple: true })).toBe(5);
});
it('leaves an existing app.db alone when a stray auth.db is also present', async () => {
diff --git a/backend/src/__tests__/erasure.test.ts b/backend/src/__tests__/erasure.test.ts
index 8e1825fa..93fba838 100644
--- a/backend/src/__tests__/erasure.test.ts
+++ b/backend/src/__tests__/erasure.test.ts
@@ -4,9 +4,10 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { closeDb } from '../db.js';
-import { eraseLoggedData } from '../erasure.js';
+import { eraseAccountData, eraseLoggedData } from '../erasure.js';
import { appendLog } from '../logging.js';
import { deletePosthogPerson } from '../posthog.js';
+import { createRoom, listRooms } from '../rooms.js';
import {
DELETED_USER_ID,
recordUsage,
@@ -73,19 +74,37 @@ function logExists(userId: string): boolean {
}
describe('eraseLoggedData', () => {
- it('removes the study log and the analytics profile together', async () => {
+ it('removes logged activity while preserving active rooms', async () => {
await logSomething('usr-1');
+ createRoom('usr-1', 'Private draft', {
+ beforeCursor: 'private', selectedText: '', afterCursor: '',
+ });
expect(logExists('usr-1')).toBe(true);
+ expect(listRooms('usr-1')).toHaveLength(1);
await eraseLoggedData('usr-1');
expect(logExists('usr-1')).toBe(false);
+ expect(listRooms('usr-1')).toHaveLength(1);
expect(deletePosthogPerson).toHaveBeenCalledWith('usr-1');
});
});
describe('account deletion (Better Auth beforeDelete)', () => {
- it('does everything withdrawal does, and anonymizes the usage rows on top', async () => {
+ it('still deletes rooms when a shared activity-erasure operation fails', async () => {
+ const invalidLogKey = '../usr-partial';
+ createRoom(invalidLogKey, 'Private draft', {
+ beforeCursor: 'private', selectedText: '', afterCursor: '',
+ });
+
+ await expect(eraseAccountData(invalidLogKey)).rejects.toBeInstanceOf(
+ AggregateError,
+ );
+ expect(deletePosthogPerson).toHaveBeenCalledWith(invalidLogKey);
+ expect(listRooms(invalidLogKey)).toEqual([]);
+ });
+
+ it('includes every activity erasure, then deletes rooms and anonymizes usage', async () => {
// Imported here, not at module load: auth.ts opens the DB as a side effect of
// evaluation, so it has to see this test's temp DATA_DIR.
process.env.BETTER_AUTH_SECRET = 'x'.repeat(32);
@@ -96,15 +115,19 @@ describe('account deletion (Better Auth beforeDelete)', () => {
await logSomething('usr-gone');
meterSomething('usr-gone');
+ createRoom('usr-gone', 'Private draft', {
+ beforeCursor: 'private', selectedText: '', afterCursor: '',
+ });
const beforeDelete = auth.options.user?.deleteUser?.beforeDelete;
expect(beforeDelete).toBeDefined();
// Better Auth hands the hook the full user record; only the id is read.
await beforeDelete?.({ id: 'usr-gone' } as never);
- // The withdrawal erasure — this is the half account deletion used to skip.
+ // Full account erasure includes both activity records and room snapshots.
expect(logExists('usr-gone')).toBe(false);
expect(deletePosthogPerson).toHaveBeenCalledWith('usr-gone');
+ expect(listRooms('usr-gone')).toEqual([]);
// ...plus the tombstone: the spend survives, detached from the person.
const rows = summarizeUsage(0, Date.now() + 1000);
diff --git a/backend/src/__tests__/oauth-room-authorization.test.ts b/backend/src/__tests__/oauth-room-authorization.test.ts
new file mode 100644
index 00000000..7248716c
--- /dev/null
+++ b/backend/src/__tests__/oauth-room-authorization.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+import { parseOAuthAuthorizationQuery } from '../oauth-room-authorization.js';
+
+describe('OAuth room authorization context', () => {
+ it('derives the exact launched room and client from the verified query', () => {
+ expect(
+ parseOAuthAuthorizationQuery(
+ new URLSearchParams({
+ state: 'room_exact.random-csrf',
+ client_id: 'dynamic-client',
+ redirect_uri: 'https://mindmap.example/callback',
+ }).toString(),
+ ),
+ ).toEqual({
+ state: 'room_exact.random-csrf',
+ roomId: 'room_exact',
+ clientId: 'dynamic-client',
+ redirectUri: 'https://mindmap.example/callback',
+ });
+ });
+
+ it('fails closed when the authorization lacks a room-bound state', () => {
+ expect(() =>
+ parseOAuthAuthorizationQuery(
+ 'state=random&client_id=client&redirect_uri=https%3A%2F%2Fapp.example',
+ ),
+ ).toThrow();
+ });
+});
diff --git a/backend/src/__tests__/oauth-room-middleware.integration.test.ts b/backend/src/__tests__/oauth-room-middleware.integration.test.ts
new file mode 100644
index 00000000..fec49a32
--- /dev/null
+++ b/backend/src/__tests__/oauth-room-middleware.integration.test.ts
@@ -0,0 +1,154 @@
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
+import { getMigrations } from 'better-auth/db/migration';
+import type { Auth } from '../auth.js';
+import { closeDb } from '../db.js';
+import { createRoom } from '../rooms.js';
+
+const AUTH_BASE = 'http://localhost:8000/api/auth';
+let auth: Auth;
+let dataDir: string;
+
+function authRequest(pathname: string, init?: RequestInit): Promise {
+ return auth.handler(new Request(`${AUTH_BASE}${pathname}`, init));
+}
+
+function cookieHeader(response: Response): string {
+ const headers = response.headers as Headers & { getSetCookie?: () => string[] };
+ const values = headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? ''];
+ return values
+ .filter(Boolean)
+ .map((value) => value.split(';', 1)[0])
+ .join('; ');
+}
+
+beforeAll(async () => {
+ dataDir = mkdtempSync(path.join(tmpdir(), 'writing-tools-oauth-middleware-'));
+ process.env.DATA_DIR = dataDir;
+ process.env.BETTER_AUTH_SECRET = 'integration-secret-that-is-at-least-32-characters';
+ process.env.BETTER_AUTH_URL = 'http://localhost:8000';
+ process.env.GOOGLE_CLIENT_ID = 'test-client';
+ process.env.GOOGLE_CLIENT_SECRET = 'test-secret';
+ ({ auth } = await import('../auth.js'));
+ const { runMigrations } = await getMigrations(auth.options);
+ await runMigrations();
+});
+
+afterAll(() => {
+ vi.useRealTimers();
+ closeDb();
+ rmSync(dataDir, { recursive: true, force: true });
+});
+
+describe('signed oauth_query middleware coupling', () => {
+ it('populates verified state, gates client metadata, and rejects tampering or expiry', async () => {
+ const registration = await authRequest('/oauth2/register', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ redirect_uris: ['https://mindmap.example/callback'],
+ client_name: 'Integration Mindmap',
+ client_uri: 'https://mindmap.example',
+ token_endpoint_auth_method: 'none',
+ grant_types: ['authorization_code'],
+ response_types: ['code'],
+ scope: 'openai:chat doc:read',
+ type: 'user-agent-based',
+ }),
+ });
+ expect(registration.status).toBe(200);
+ const registered = (await registration.json()) as { client_id: string };
+
+ const signIn = await authRequest('/sign-in/anonymous', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Origin: 'http://localhost:8000',
+ },
+ body: '{}',
+ });
+ expect(signIn.status).toBe(200);
+ const signedIn = (await signIn.json()) as { user: { id: string } };
+ const cookie = cookieHeader(signIn);
+ expect(cookie).toContain('session_token=');
+
+ const room = createRoom(signedIn.user.id, 'Signed query draft', {
+ beforeCursor: 'private', selectedText: '', afterCursor: '',
+ });
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
+ const authorize = new URL(`${AUTH_BASE}/oauth2/authorize`);
+ authorize.search = new URLSearchParams({
+ client_id: registered.client_id,
+ redirect_uri: 'https://mindmap.example/callback',
+ response_type: 'code',
+ scope: 'openai:chat doc:read',
+ state: `${room.id}.random-csrf`,
+ code_challenge: 'a'.repeat(43),
+ code_challenge_method: 'S256',
+ }).toString();
+ const authorization = await auth.handler(
+ new Request(authorize, { headers: { Cookie: cookie } }),
+ );
+ expect(authorization.status).toBe(302);
+ const roomPage = new URL(
+ authorization.headers.get('location') ?? '',
+ AUTH_BASE,
+ );
+ expect(roomPage.pathname).toBe('/api/oauth/room');
+ const oauthQuery = roomPage.search.slice(1);
+ expect(oauthQuery).toContain('sig=');
+
+ const context = await authRequest('/oauth2/room-context', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Cookie: cookie,
+ Origin: 'http://localhost:8000',
+ },
+ body: JSON.stringify({ oauth_query: oauthQuery }),
+ });
+ expect(context.status).toBe(200);
+ expect(await context.json()).toMatchObject({
+ room: { id: room.id, name: 'Signed query draft' },
+ client: {
+ id: registered.client_id,
+ name: 'Integration Mindmap',
+ redirect_origin: 'https://mindmap.example',
+ },
+ selected: false,
+ });
+
+ const tampered = new URLSearchParams(oauthQuery);
+ tampered.set('state', 'room_attacker.random-csrf');
+ const tamperedResponse = await authRequest('/oauth2/room-context', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Cookie: cookie },
+ body: JSON.stringify({ oauth_query: tampered.toString() }),
+ });
+ expect(tamperedResponse.status).toBe(400);
+
+ vi.setSystemTime(new Date('2026-01-01T00:11:00Z'));
+ const expired = await authRequest('/oauth2/room-context', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Cookie: cookie },
+ body: JSON.stringify({ oauth_query: oauthQuery }),
+ });
+ expect(expired.status).toBe(400);
+
+ vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
+ const authContext = await auth.$context;
+ await authContext.adapter.delete({
+ model: 'oauthClient',
+ where: [{ field: 'clientId', value: registered.client_id }],
+ });
+ const missingClient = await authRequest('/oauth2/room-context', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Cookie: cookie },
+ body: JSON.stringify({ oauth_query: oauthQuery }),
+ });
+ expect(missingClient.status).toBe(400);
+ });
+});
diff --git a/backend/src/__tests__/oauth-room-resource.test.ts b/backend/src/__tests__/oauth-room-resource.test.ts
new file mode 100644
index 00000000..ae4c6d7f
--- /dev/null
+++ b/backend/src/__tests__/oauth-room-resource.test.ts
@@ -0,0 +1,67 @@
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { createApp } from '../app.js';
+import { closeDb } from '../db.js';
+import { createRoom } from '../rooms.js';
+
+let dir: string;
+
+beforeEach(() => {
+ dir = mkdtempSync(path.join(tmpdir(), 'writing-tools-oauth-resource-'));
+ process.env.DATA_DIR = dir;
+});
+
+afterEach(() => {
+ closeDb();
+ rmSync(dir, { recursive: true, force: true });
+ delete process.env.DATA_DIR;
+});
+
+describe('GET /api/rooms/:roomId', () => {
+ it('serves only the room named by the verified claim and owned by its subject', async () => {
+ const room = createRoom('owner', 'Draft', {
+ beforeCursor: 'private', selectedText: '', afterCursor: '',
+ });
+ const verifyOAuthAccessToken = vi.fn().mockResolvedValue({
+ sub: 'owner',
+ room_id: room.id,
+ });
+ const app = createApp({ verifyOAuthAccessToken });
+ const response = await app.request(`/api/rooms/${room.id}`, {
+ headers: { Authorization: 'Bearer header.payload.signature' },
+ });
+ expect(response.status).toBe(200);
+ expect(await response.json()).toMatchObject({
+ id: room.id,
+ doc: { beforeCursor: 'private' },
+ });
+ expect(verifyOAuthAccessToken).toHaveBeenCalledWith(
+ 'header.payload.signature',
+ {
+ verifyOptions: { audience: expect.any(String) },
+ scopes: ['doc:read'],
+ },
+ );
+ });
+
+ it('rejects a valid token whose room claim does not match the path', async () => {
+ const requested = createRoom('owner', 'Requested', {
+ beforeCursor: '', selectedText: 'requested', afterCursor: '',
+ });
+ const other = createRoom('owner', 'Other', {
+ beforeCursor: '', selectedText: 'other', afterCursor: '',
+ });
+ const app = createApp({
+ verifyOAuthAccessToken: vi.fn().mockResolvedValue({
+ sub: 'owner',
+ room_id: other.id,
+ }),
+ });
+ const response = await app.request(`/api/rooms/${requested.id}`, {
+ headers: { Authorization: 'Bearer header.payload.signature' },
+ });
+ expect(response.status).toBe(403);
+ });
+});
diff --git a/backend/src/__tests__/rooms.test.ts b/backend/src/__tests__/rooms.test.ts
index fc3da7cf..e11ef518 100644
--- a/backend/src/__tests__/rooms.test.ts
+++ b/backend/src/__tests__/rooms.test.ts
@@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { closeDb } from '../db.js';
import {
createRoom,
+ consumeRoomSelection,
+ deleteRoomsForUser,
getRoomForUser,
listRooms,
selectRoomForOAuth,
@@ -36,16 +38,42 @@ describe('rooms', () => {
expect(getRoomForUser(room.id, 'other')).toBeNull();
});
- it('binds an OAuth session only after checking room ownership', () => {
+ it('binds an OAuth request only after checking room ownership', () => {
const room = createRoom('owner', 'Draft', {
beforeCursor: '',
selectedText: 'draft',
afterCursor: '',
});
- expect(selectRoomForOAuth('session', 'other', room.id)).toBe(false);
- expect(selectedRoomForOAuth('session', 'other')).toBeUndefined();
- expect(selectRoomForOAuth('session', 'owner', room.id)).toBe(true);
- expect(selectedRoomForOAuth('session', 'owner')).toBe(room.id);
- expect(selectedRoomForOAuth('session', 'other')).toBeUndefined();
+ expect(selectRoomForOAuth('session', 'state-a', 'other', room.id)).toBe(false);
+ expect(selectedRoomForOAuth('session', 'state-a', 'other')).toBeUndefined();
+ expect(selectRoomForOAuth('session', 'state-a', 'owner', room.id)).toBe(true);
+ expect(selectedRoomForOAuth('session', 'state-a', 'owner')).toBe(room.id);
+ expect(selectedRoomForOAuth('session', 'state-a', 'other')).toBeUndefined();
+ });
+
+ it('isolates concurrent OAuth authorizations and consumes only the exchanged one', () => {
+ const first = createRoom('owner', 'First', {
+ beforeCursor: '', selectedText: 'first', afterCursor: '',
+ });
+ const second = createRoom('owner', 'Second', {
+ beforeCursor: '', selectedText: 'second', afterCursor: '',
+ });
+ expect(selectRoomForOAuth('session', 'state-a', 'owner', first.id)).toBe(true);
+ expect(selectRoomForOAuth('session', 'state-b', 'owner', second.id)).toBe(true);
+ expect(selectedRoomForOAuth('session', 'state-a', 'owner')).toBe(first.id);
+ expect(selectedRoomForOAuth('session', 'state-b', 'owner')).toBe(second.id);
+ consumeRoomSelection('session', 'state-a');
+ expect(selectedRoomForOAuth('session', 'state-a', 'owner')).toBeUndefined();
+ expect(selectedRoomForOAuth('session', 'state-b', 'owner')).toBe(second.id);
+ });
+
+ it('deletes durable rooms and their pending OAuth selections for erasure', async () => {
+ const room = createRoom('owner', 'Private draft', {
+ beforeCursor: 'private', selectedText: '', afterCursor: '',
+ });
+ expect(selectRoomForOAuth('session', 'state', 'owner', room.id)).toBe(true);
+ await deleteRoomsForUser('owner');
+ expect(listRooms('owner')).toEqual([]);
+ expect(selectedRoomForOAuth('session', 'state', 'owner')).toBeUndefined();
});
});
diff --git a/backend/src/app.ts b/backend/src/app.ts
index 3b12e32b..33250389 100644
--- a/backend/src/app.ts
+++ b/backend/src/app.ts
@@ -78,11 +78,25 @@ function logSecretGate(c: Context, provided: string): Response | null {
return null;
}
-export function createApp({ auth }: { auth?: Auth } = {}): Hono {
+type OAuthAccessTokenVerifier = (
+ token: string,
+ options: { verifyOptions: { audience: string }; scopes: string[] },
+) => Promise>;
+
+export function createApp({
+ auth,
+ verifyOAuthAccessToken: suppliedOAuthVerifier,
+}: {
+ auth?: Auth;
+ /** Test seam for the resource-server boundary; production derives it from auth. */
+ verifyOAuthAccessToken?: OAuthAccessTokenVerifier;
+} = {}): Hono {
const app = new Hono();
- const verifyOAuthAccessToken = auth?.options
- ? oauthProviderResourceClient(auth).getActions().verifyAccessToken
- : null;
+ const verifyOAuthAccessToken =
+ suppliedOAuthVerifier ??
+ (auth?.options
+ ? oauthProviderResourceClient(auth).getActions().verifyAccessToken
+ : null);
// CORS stays fully permissive for now to preserve existing behaviour.
app.use('*', cors({ exposeHeaders: [PLATFORM_AUTH_ERROR_HEADER] }));
@@ -442,9 +456,9 @@ export function createApp({ auth }: { auth?: Auth } = {}): Hono {
return c.json({ loggingConsent: level });
});
- // Erase the authenticated user's logged activity — study logs and analytics
- // profile — while keeping their account. This is *withdrawal*: they carry on
- // using the add-in, they just want what we've recorded about them gone.
+ // Erase the authenticated user's logged activity — study logs and analytics —
+ // while keeping their account and active room snapshots. This is *withdrawal*:
+ // they carry on using the add-in and any open room-backed tools.
//
// Not "delete my data", which is what this used to be called: it doesn't touch
// the account, and it deliberately leaves the LLM usage rows, because the
diff --git a/backend/src/auth.ts b/backend/src/auth.ts
index 8bf9bddc..38d39e41 100644
--- a/backend/src/auth.ts
+++ b/backend/src/auth.ts
@@ -22,10 +22,17 @@ import {
FULL_CONSENT_LEVEL,
} from './consent.js';
import { db } from './db.js';
-import { eraseLoggedData } from './erasure.js';
+import { eraseAccountData } from './erasure.js';
import { anonymizeUserUsage } from './usage.js';
import { isUserAllowed } from './userAllowlist.js';
-import { selectedRoomForOAuth } from './rooms.js';
+import {
+ consumeRoomSelection,
+ selectedRoomForOAuth,
+} from './rooms.js';
+import {
+ currentOAuthAuthorization,
+ roomOAuthAuthorization,
+} from './oauth-room-authorization.js';
// A module-level `auth` singleton (not a factory) so the Better Auth CLI can
// auto-discover it: `npx @better-auth/cli migrate` looks for an exported `auth`
@@ -98,16 +105,16 @@ export const auth = betterAuth({
// Google-only accounts have no password, so deletion proceeds from the
// session alone (no verification flow configured).
//
- // beforeDelete runs the same erasure as the withdrawal endpoint (study logs
- // + analytics profile), so account deletion is by construction a superset of
- // it — see erasure.ts. On top of that it anonymizes the LLM usage rows rather
+ // beforeDelete removes study logs, the analytics profile, and durable rooms;
+ // activity withdrawal intentionally preserves rooms for active tools. See
+ // erasure.ts. It then anonymizes the LLM usage rows rather
// than deleting them: they're content-free billing records, and dropping them
// would make our per-user spend stop reconciling with the provider's invoice
// (see usage.ts). Better Auth then drops the account, sessions and OAuth links.
deleteUser: {
enabled: true,
beforeDelete: async (user) => {
- await eraseLoggedData(user.id);
+ await eraseAccountData(user.id);
anonymizeUserUsage(user.id);
},
},
@@ -125,11 +132,22 @@ export const auth = betterAuth({
accessTokenExpiresIn: 60 * 60,
postLogin: {
page: '/api/oauth/room',
- shouldRedirect: ({ user, session }) =>
- !selectedRoomForOAuth(session.id, user.id),
- consentReferenceId: ({ user, session, scopes }) => {
+ shouldRedirect: async ({ user, session }) => {
+ const authorization = await currentOAuthAuthorization();
+ return !selectedRoomForOAuth(
+ session.id,
+ authorization.state,
+ user.id,
+ );
+ },
+ consentReferenceId: async ({ user, session, scopes }) => {
if (!scopes.includes('doc:read')) return undefined;
- const roomId = selectedRoomForOAuth(session.id, user.id);
+ const authorization = await currentOAuthAuthorization();
+ const roomId = selectedRoomForOAuth(
+ session.id,
+ authorization.state,
+ user.id,
+ );
if (!roomId) throw new Error('A room must be selected for doc:read.');
return roomId;
},
@@ -137,12 +155,14 @@ export const auth = betterAuth({
customAccessTokenClaims: ({ referenceId }) => ({
...(referenceId ? { room_id: referenceId } : {}),
}),
- customTokenResponseFields: ({ verificationValue }) => ({
- ...(verificationValue?.referenceId
- ? { room_id: verificationValue.referenceId }
- : {}),
- }),
+ customTokenResponseFields: ({ verificationValue }) => {
+ if (!verificationValue?.referenceId) return {};
+ const state = verificationValue.query.state;
+ if (state) consumeRoomSelection(verificationValue.sessionId, state);
+ return { room_id: verificationValue.referenceId };
+ },
}),
+ roomOAuthAuthorization(),
bearer(),
// Demo mode. signIn.anonymous() mints a real user + session, so the whole
// identity-keyed stack (resolveUser, /api/log, consent gating, usage
diff --git a/backend/src/db.ts b/backend/src/db.ts
index 081d370f..9f6b389c 100644
--- a/backend/src/db.ts
+++ b/backend/src/db.ts
@@ -144,6 +144,24 @@ const MIGRATIONS: Array<(conn: Database.Database) => void> = [
CREATE INDEX oauth_room_selection_expiry ON oauth_room_selection (expires_at);
`);
},
+ // v5 — key the transient room choice to one OAuth authorization request, not
+ // merely to the browser session. A user can authorize in multiple tabs; one
+ // tab must never supply or overwrite the room for another tab's grant.
+ (conn) => {
+ conn.exec(`
+ DROP TABLE oauth_room_selection;
+ CREATE TABLE oauth_room_selection (
+ session_id TEXT NOT NULL,
+ authorization_state TEXT NOT NULL,
+ user_id TEXT NOT NULL,
+ room_id TEXT NOT NULL,
+ expires_at INTEGER NOT NULL,
+ PRIMARY KEY (session_id, authorization_state),
+ FOREIGN KEY (room_id) REFERENCES room(id) ON DELETE CASCADE
+ );
+ CREATE INDEX oauth_room_selection_expiry ON oauth_room_selection (expires_at);
+ `);
+ },
];
function migrate(conn: Database.Database): void {
diff --git a/backend/src/erasure.ts b/backend/src/erasure.ts
index cc642732..63d2b52d 100644
--- a/backend/src/erasure.ts
+++ b/backend/src/erasure.ts
@@ -1,41 +1,51 @@
/**
- * Erasing a user's logged activity — the one definition of what "delete my data"
- * means, shared by both paths that promise it.
+ * User-data erasure has two deliberately different scopes:
*
- * Two different requests reach this:
- * - "delete my logged activity" (DELETE /api/me/activity) — withdrawal. The user
- * keeps their account and keeps using the add-in; they just want what we've
- * recorded about them gone. Their LLM usage rows stay: the account is still
- * open and still running up a bill, so it still has to be metered.
- * - "delete my account" (Better Auth's deleteUser) — departure. The `beforeDelete`
- * hook calls this too, and *then* anonymizes the usage rows and drops the
- * account, so account deletion is by construction a superset of the above.
+ * - Activity withdrawal (`DELETE /api/me/activity`) removes study logs and the
+ * analytics profile. It preserves active rooms because the user keeps their
+ * account and may have Mindmap open against one of those resources.
+ * - Account deletion removes those same records plus durable room document
+ * snapshots. Better Auth then deletes the account and sessions, while the
+ * caller anonymizes content-free usage rows for invoice reconciliation.
*
- * Keeping this in one function is the point: when the two paths each maintained
- * their own list, account deletion quietly forgot to purge the PostHog person —
- * the thorough option was doing less than the lesser one.
+ * Keep the two exported operations here so their difference remains explicit.
*/
import { deleteUserLogs } from './logging.js';
import { deletePosthogPerson } from './posthog.js';
+import { deleteRoomsForUser } from './rooms.js';
-/**
- * Delete everything we've logged about a user: their study-log file and their
- * analytics profile. PostHog deletion is best-effort (it needs a management API
- * key; see deletePosthogPerson) and never throws. The two touch unrelated systems,
- * so we run them concurrently and independently — a failure deleting the log file
- * must not skip the PostHog deletion, or vice versa. If either genuinely fails we
- * still surface it, so the caller (e.g. Better Auth's beforeDelete) can abort rather
- * than drop an account whose data we couldn't erase.
- */
-export async function eraseLoggedData(userId: string): Promise {
- const results = await Promise.allSettled([
- deleteUserLogs(userId),
- deletePosthogPerson(userId),
- ]);
+function loggedDataOperations(userId: string): Array> {
+ return [deleteUserLogs(userId), deletePosthogPerson(userId)];
+}
+
+async function settleErasure(
+ label: string,
+ operations: Array>,
+): Promise {
+ const results = await Promise.allSettled(operations);
const failures = results
- .filter((r): r is PromiseRejectedResult => r.status === 'rejected')
- .map((r) => r.reason);
+ .filter((result): result is PromiseRejectedResult =>
+ result.status === 'rejected',
+ )
+ .map((result) => result.reason);
if (failures.length > 0) {
- throw new AggregateError(failures, 'eraseLoggedData: partial failure');
+ throw new AggregateError(failures, `${label}: partial failure`);
}
}
+
+/** Remove logged activity without disrupting active room-backed tools. */
+export async function eraseLoggedData(userId: string): Promise {
+ await settleErasure('eraseLoggedData', loggedDataOperations(userId));
+}
+
+/**
+ * Remove all person-linked stored content before deleting the account.
+ * Keep this as a structural superset of loggedDataOperations: these paths once
+ * had separate lists and account deletion accidentally omitted PostHog data.
+ */
+export async function eraseAccountData(userId: string): Promise {
+ await settleErasure('eraseAccountData', [
+ ...loggedDataOperations(userId),
+ deleteRoomsForUser(userId),
+ ]);
+}
diff --git a/backend/src/index.ts b/backend/src/index.ts
index ae9ba15f..1b577d9d 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -51,7 +51,7 @@ if (auth) {
const { devicePageHandler } = await import('./routes/device-approval.js');
app.get('/api/device', devicePageHandler);
const { registerOAuthPages } = await import('./routes/oauth-pages.js');
- registerOAuthPages(app, auth);
+ registerOAuthPages(app);
}
// Debug UI — only when auth is enabled AND DEBUG=true. Registered here (not in
diff --git a/backend/src/oauth-room-authorization.ts b/backend/src/oauth-room-authorization.ts
new file mode 100644
index 00000000..0abe2339
--- /dev/null
+++ b/backend/src/oauth-room-authorization.ts
@@ -0,0 +1,126 @@
+import { getOAuthProviderState } from '@better-auth/oauth-provider';
+import {
+ APIError,
+ createAuthEndpoint,
+ getSessionFromCtx,
+ sessionMiddleware,
+} from 'better-auth/api';
+import { z } from 'zod';
+import {
+ getRoomForUser,
+ selectRoomForOAuth,
+ selectedRoomForOAuth,
+} from './rooms.js';
+
+interface OAuthAuthorizationContext {
+ state: string;
+ roomId: string;
+ clientId: string;
+ redirectUri: string;
+}
+
+/** Read the OAuth Provider plugin's already signature-verified request context. */
+export async function currentOAuthAuthorization(): Promise {
+ const providerState = await getOAuthProviderState();
+ if (!providerState?.query) {
+ throw new APIError('BAD_REQUEST', {
+ message: 'Missing verified OAuth authorization context.',
+ });
+ }
+ return parseOAuthAuthorizationQuery(providerState.query);
+}
+
+export function parseOAuthAuthorizationQuery(
+ value: string,
+): OAuthAuthorizationContext {
+ const query = new URLSearchParams(value);
+ const state = query.get('state') ?? '';
+ const roomId = state.split('.', 1)[0] ?? '';
+ const clientId = query.get('client_id') ?? '';
+ const redirectUri = query.get('redirect_uri') ?? '';
+ if (!state || !roomId.startsWith('room_') || !clientId || !redirectUri) {
+ throw new APIError('BAD_REQUEST', {
+ message: 'Invalid room authorization request.',
+ });
+ }
+ return { state, roomId, clientId, redirectUri };
+}
+
+export function roomOAuthAuthorization() {
+ // Load-bearing coupling with @better-auth/oauth-provider: its global before
+ // hook matches endpoints whose parsed body contains `oauth_query`, verifies the
+ // signature and expiry, then populates the async-local provider state read by
+ // currentOAuthAuthorization(). The handlers do not read this field directly,
+ // but removing it would bypass that hook and leave no verified request context.
+ const body = z.object({ oauth_query: z.string().min(1) });
+ return {
+ id: 'room-oauth-authorization',
+ endpoints: {
+ oauthRoomContext: createAuthEndpoint(
+ '/oauth2/room-context',
+ { method: 'POST', body, use: [sessionMiddleware] },
+ async (ctx) => {
+ const session = await getSessionFromCtx(ctx);
+ if (!session) throw new APIError('UNAUTHORIZED');
+ const authorization = await currentOAuthAuthorization();
+ const room = getRoomForUser(
+ authorization.roomId,
+ session.user.id,
+ );
+ if (!room) {
+ throw new APIError('NOT_FOUND', {
+ message: 'The launched room was not found for this account.',
+ });
+ }
+ const client = (await ctx.context.adapter.findOne({
+ model: 'oauthClient',
+ where: [{ field: 'clientId', value: authorization.clientId }],
+ })) as
+ | { clientId: string; name?: string | null; redirectUris?: string[] }
+ | null;
+ if (!client?.redirectUris?.includes(authorization.redirectUri)) {
+ throw new APIError('BAD_REQUEST', {
+ message: 'The OAuth client or redirect URI is no longer registered.',
+ });
+ }
+ return ctx.json({
+ room: { id: room.id, name: room.name },
+ client: {
+ id: client.clientId,
+ name: client.name || client.clientId,
+ redirect_origin: new URL(authorization.redirectUri).origin,
+ },
+ selected:
+ selectedRoomForOAuth(
+ session.session.id,
+ authorization.state,
+ session.user.id,
+ ) === room.id,
+ });
+ },
+ ),
+ authorizeOAuthRoom: createAuthEndpoint(
+ '/oauth2/authorize-room',
+ { method: 'POST', body, use: [sessionMiddleware] },
+ async (ctx) => {
+ const session = await getSessionFromCtx(ctx);
+ if (!session) throw new APIError('UNAUTHORIZED');
+ const authorization = await currentOAuthAuthorization();
+ if (
+ !selectRoomForOAuth(
+ session.session.id,
+ authorization.state,
+ session.user.id,
+ authorization.roomId,
+ )
+ ) {
+ throw new APIError('NOT_FOUND', {
+ message: 'The launched room was not found for this account.',
+ });
+ }
+ return ctx.json({ ok: true });
+ },
+ ),
+ },
+ } as const;
+}
diff --git a/backend/src/rooms.ts b/backend/src/rooms.ts
index b50ce191..ddf713d8 100644
--- a/backend/src/rooms.ts
+++ b/backend/src/rooms.ts
@@ -83,9 +83,11 @@ export function getRoomForUser(roomId: string, userId: string): Room | null {
export function selectRoomForOAuth(
sessionId: string,
+ authorizationState: string,
userId: string,
roomId: string,
): boolean {
+ if (!authorizationState) return false;
if (!getRoomForUser(roomId, userId)) return false;
const now = Date.now();
db()
@@ -93,26 +95,54 @@ export function selectRoomForOAuth(
.run(now);
db()
.prepare(
- `INSERT INTO oauth_room_selection (session_id, user_id, room_id, expires_at)
- VALUES (?, ?, ?, ?)
- ON CONFLICT(session_id) DO UPDATE SET
+ `INSERT INTO oauth_room_selection
+ (session_id, authorization_state, user_id, room_id, expires_at)
+ VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT(session_id, authorization_state) DO UPDATE SET
user_id = excluded.user_id,
room_id = excluded.room_id,
expires_at = excluded.expires_at`,
)
- .run(sessionId, userId, roomId, now + ROOM_SELECTION_TTL_MS);
+ .run(
+ sessionId,
+ authorizationState,
+ userId,
+ roomId,
+ now + ROOM_SELECTION_TTL_MS,
+ );
return true;
}
export function selectedRoomForOAuth(
sessionId: string,
+ authorizationState: string,
userId: string,
): string | undefined {
+ if (!authorizationState) return undefined;
const row = db()
.prepare(
`SELECT room_id FROM oauth_room_selection
- WHERE session_id = ? AND user_id = ? AND expires_at > ?`,
+ WHERE session_id = ? AND authorization_state = ?
+ AND user_id = ? AND expires_at > ?`,
)
- .get(sessionId, userId, Date.now()) as { room_id: string } | undefined;
+ .get(sessionId, authorizationState, userId, Date.now()) as
+ | { room_id: string }
+ | undefined;
return row?.room_id;
}
+
+export function consumeRoomSelection(
+ sessionId: string,
+ authorizationState: string,
+): void {
+ db()
+ .prepare(
+ `DELETE FROM oauth_room_selection
+ WHERE session_id = ? AND authorization_state = ?`,
+ )
+ .run(sessionId, authorizationState);
+}
+
+export async function deleteRoomsForUser(userId: string): Promise {
+ db().prepare(`DELETE FROM room WHERE owner_user_id = ?`).run(userId);
+}
diff --git a/backend/src/routes/oauth-pages.ts b/backend/src/routes/oauth-pages.ts
index 33041d4f..7910b08e 100644
--- a/backend/src/routes/oauth-pages.ts
+++ b/backend/src/routes/oauth-pages.ts
@@ -1,17 +1,8 @@
-import type { Context } from 'hono';
-import type { Auth } from '../auth.js';
-import {
- getRoomForUser,
- listRooms,
- selectRoomForOAuth,
- selectedRoomForOAuth,
-} from '../rooms.js';
-
const PAGE_STYLE = `
body{font:16px/1.5 system-ui,sans-serif;max-width:620px;margin:3rem auto;padding:0 1.5rem;color:#172033}
h1{font-size:1.5rem}button{font:inherit;padding:.65rem 1rem;border:1px solid #aab2c0;border-radius:7px;cursor:pointer}
-.primary{background:#3157d5;color:white;border-color:#3157d5}.room{display:block;width:100%;text-align:left;margin:.7rem 0}
-.muted{color:#657087}.error{color:#b42318}.actions{display:flex;gap:.7rem;margin-top:1.5rem}`;
+.primary{background:#3157d5;color:white;border-color:#3157d5}.muted{color:#657087}.error{color:#b42318}
+.actions{display:flex;gap:.7rem;margin-top:1.5rem}`;
function shell(title: string, script: string): string {
return `${title}${title}
Loading…
`;
@@ -21,25 +12,23 @@ const helpers = `
const app=document.getElementById('app');
function el(tag,text,cls){const node=document.createElement(tag);if(text!==undefined)node.textContent=text;if(cls)node.className=cls;return node}
function show(...nodes){app.replaceChildren(...nodes)}
-async function json(url,init){const response=await fetch(url,{credentials:'include',...init});const body=await response.json().catch(()=>({}));if(!response.ok)throw new Error(body.detail||body.error_description||body.error||('Request failed ('+response.status+')'));return body}
+async function json(url,init){const response=await fetch(url,{credentials:'include',...init});const body=await response.json().catch(()=>({}));if(!response.ok)throw new Error(body.message||body.detail||body.error_description||body.error||('Request failed ('+response.status+')'));return body}
+function oauthBody(extra){return JSON.stringify({...extra,oauth_query:location.search.slice(1)})}
function go(result){const url=result.url||result.redirect_uri;if(url)location.href=url;else throw new Error('Authorization server returned no redirect URL')}
`;
const LOGIN_HTML = shell(
- 'Connect Mindmap',
+ 'Connect an app',
`${helpers}
async function start(){
const session=await json('/api/auth/get-session').catch(()=>null);
- if(session?.user){
- location.replace('/api/auth/oauth2/authorize'+location.search);
- return;
- }
- const p=el('p','Sign in to choose which Writing Tools room Mindmap may open.');
+ if(session?.user){location.replace('/api/auth/oauth2/authorize'+location.search);return}
+ const p=el('p','Sign in to authorize the Writing Tools room opened by this app.');
const b=el('button','Sign in with Google','primary');
b.onclick=async()=>{try{
- const callbackURL=location.href;
- const result=await json('/api/auth/sign-in/social',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({provider:'google',callbackURL,errorCallbackURL:callbackURL})});
- location.href=result.url;
+ const callbackURL=location.href;
+ const result=await json('/api/auth/sign-in/social',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({provider:'google',callbackURL,errorCallbackURL:callbackURL})});
+ location.href=result.url;
}catch(error){show(el('p',String(error),'error'))}};
show(p,b);
}
@@ -47,28 +36,21 @@ start();`,
);
const ROOM_HTML = shell(
- 'Choose a room',
+ 'Authorize this room',
`${helpers}
async function start(){
try{
- const rooms=await json('/api/oauth/rooms');
- const params=new URLSearchParams(location.search);
- const hint=(params.get('state')||'').split('.')[0];
- const intro=el('p','Mindmap will receive access only to the room you choose.','muted');
- const nodes=[intro];
- for(const room of rooms){
- const b=el('button',room.name+(room.id===hint?' — from this launch':''),'room'+(room.id===hint?' primary':''));
- b.onclick=()=>choose(room.id);
- nodes.push(b);
- }
- if(!rooms.length)nodes.push(el('p','No rooms are available for this account. Return to Writing Tools and launch Mindmap again.','error'));
- show(...nodes);
+ const context=await json('/api/auth/oauth2/room-context',{method:'POST',headers:{'Content-Type':'application/json'},body:oauthBody({})});
+ const intro=el('p',context.client.name+' ('+context.client.redirect_origin+') wants access to the room opened from Writing Tools:');
+ const room=el('p',context.room.name+' ('+context.room.id+')','muted');
+ const button=el('button','Continue','primary');button.onclick=choose;
+ show(intro,room,button);
}catch(error){show(el('p',String(error),'error'))}
}
-async function choose(roomId){
+async function choose(){
try{
- await json('/api/oauth/room/select',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({room_id:roomId})});
- const result=await json('/api/auth/oauth2/continue',{method:'POST',headers:{'Content-Type':'application/json','Accept':'application/json'},body:JSON.stringify({postLogin:true,oauth_query:location.search.slice(1)})});
+ await json('/api/auth/oauth2/authorize-room',{method:'POST',headers:{'Content-Type':'application/json'},body:oauthBody({})});
+ const result=await json('/api/auth/oauth2/continue',{method:'POST',headers:{'Content-Type':'application/json','Accept':'application/json'},body:oauthBody({postLogin:true})});
go(result);
}catch(error){show(el('p',String(error),'error'))}
}
@@ -76,12 +58,13 @@ start();`,
);
const CONSENT_HTML = shell(
- 'Allow Mindmap access?',
+ 'Allow access?',
`${helpers}
async function start(){
try{
- const room=await json('/api/oauth/selected-room');
- const body=el('p','Mindmap wants to read “'+room.name+'” and use Writing Tools AI on your behalf.');
+ const context=await json('/api/auth/oauth2/room-context',{method:'POST',headers:{'Content-Type':'application/json'},body:oauthBody({})});
+ if(!context.selected)throw new Error('This authorization has no selected room.');
+ const body=el('p',context.client.name+' ('+context.client.redirect_origin+') wants to read “'+context.room.name+'” and use Writing Tools AI on your behalf.');
const note=el('p','The access token is limited to this room and expires in one hour.','muted');
const actions=el('div',undefined,'actions');
const allow=el('button','Allow','primary');allow.onclick=()=>decide(true);
@@ -91,49 +74,15 @@ async function start(){
}
async function decide(accept){
try{
- const result=await json('/api/auth/oauth2/consent',{method:'POST',headers:{'Content-Type':'application/json','Accept':'application/json'},body:JSON.stringify({accept,oauth_query:location.search.slice(1)})});
+ const result=await json('/api/auth/oauth2/consent',{method:'POST',headers:{'Content-Type':'application/json','Accept':'application/json'},body:oauthBody({accept})});
go(result);
}catch(error){show(el('p',String(error),'error'))}
}
start();`,
);
-export function registerOAuthPages(app: import('hono').Hono, auth: Auth): void {
+export function registerOAuthPages(app: import('hono').Hono): void {
app.get('/api/oauth/login', (c) => c.html(LOGIN_HTML));
app.get('/api/oauth/room', (c) => c.html(ROOM_HTML));
app.get('/api/oauth/consent', (c) => c.html(CONSENT_HTML));
-
- app.get('/api/oauth/rooms', async (c) => {
- const session = await auth.api.getSession({ headers: c.req.raw.headers });
- if (!session) return c.json({ detail: 'Unauthorized' }, 401);
- return c.json(
- listRooms(session.user.id).map(({ id, name, updatedAt }) => ({
- id,
- name,
- updated_at: updatedAt,
- })),
- );
- });
-
- app.post('/api/oauth/room/select', async (c) => {
- const session = await auth.api.getSession({ headers: c.req.raw.headers });
- if (!session) return c.json({ detail: 'Unauthorized' }, 401);
- const body = (await c.req.json().catch(() => ({}))) as { room_id?: unknown };
- if (
- typeof body.room_id !== 'string' ||
- !selectRoomForOAuth(session.session.id, session.user.id, body.room_id)
- ) {
- return c.json({ detail: 'Room not found for this account.' }, 404);
- }
- return c.json({ ok: true });
- });
-
- app.get('/api/oauth/selected-room', async (c) => {
- const session = await auth.api.getSession({ headers: c.req.raw.headers });
- if (!session) return c.json({ detail: 'Unauthorized' }, 401);
- const roomId = selectedRoomForOAuth(session.session.id, session.user.id);
- const room = roomId ? getRoomForUser(roomId, session.user.id) : null;
- if (!room) return c.json({ detail: 'Choose a room first.' }, 409);
- return c.json({ id: room.id, name: room.name });
- });
}
diff --git a/docs/oauth-rooms-pkce-poc.md b/docs/oauth-rooms-pkce-poc.md
index 4412598e..abc60b96 100644
--- a/docs/oauth-rooms-pkce-poc.md
+++ b/docs/oauth-rooms-pkce-poc.md
@@ -28,14 +28,16 @@ Possession of it grants nothing.
3. Mindmap registers as an OAuth public browser client (cached in local storage),
creates a random PKCE verifier, stores the verifier in session storage, and sends
only its SHA-256 challenge to `/api/auth/oauth2/authorize`. The non-secret room
- hint is included in the standard `state` value so the picker can highlight it;
- the complete state also contains random bytes and is matched exactly at callback.
+ id is included in the standard `state` value to bind this authorization request
+ to the exact launched room; the complete state also contains random bytes and
+ is matched exactly at callback.
4. The authorization server authenticates the writer in the system browser. This
may require Google sign-in because the Office taskpane and system browser do not
necessarily share cookies.
-5. Better Auth's `postLogin` hook sends the writer to the room picker. The backend
- accepts a choice only when that signed-in user owns the room.
-6. `consentReferenceId` returns the selected room id. The OAuth Provider plugin
+5. Better Auth's `postLogin` hook sends the writer to a confirmation screen for
+ the exact room named by the signed authorization request. The backend verifies
+ that the signed-in user owns that room; there is no independent room picker.
+6. `consentReferenceId` returns the request-bound room id. The OAuth Provider plugin
carries it through consent, authorization code, and access token.
7. Mindmap receives the code at its registered redirect URI and exchanges it with
the locally retained verifier. The verifier never travels in the launcher URL.
@@ -66,8 +68,11 @@ Possession of it grants nothing.
Mindmap client (or tightly rate-limit/validate registration) and disable open
registration.
- Tokens expire after one hour; refresh tokens are not enabled.
-- The room picker's pending selection is session-scoped and expires after ten
- minutes.
+- A confirmed room authorization is keyed by both session and OAuth `state`,
+ expires after ten minutes, and is consumed when the authorization code is
+ exchanged. Concurrent authorization tabs cannot overwrite each other.
+- Deleting logged activity preserves active rooms. Account deletion removes room
+ snapshots and their pending authorization rows.
- This is bearer-token security. PKCE prevents interception of the authorization
code; it does not make a stolen access token unusable. Sender-constrained tokens
(for example DPoP) would be a separate layer.
diff --git a/frontend/src/account/AccountPage.tsx b/frontend/src/account/AccountPage.tsx
index 8221b711..ab09ea26 100644
--- a/frontend/src/account/AccountPage.tsx
+++ b/frontend/src/account/AccountPage.tsx
@@ -143,8 +143,9 @@ export function AccountPage() {
Deletes the logged events and analytics profile we hold
about how you use the app.{' '}
Your account stays and you can keep using
- the app. This does not delete the content-free AI usage
- records we keep for billing.
+ the app. Active Mindmap rooms stay available. This does not
+ delete the content-free AI usage records we keep for
+ billing.
{eraseStatus === 'done' ? (
diff --git a/frontend/src/pages/tools/__tests__/tools-config.test.ts b/frontend/src/pages/tools/__tests__/tools-config.test.ts
index 81209cd6..e5550fdd 100644
--- a/frontend/src/pages/tools/__tests__/tools-config.test.ts
+++ b/frontend/src/pages/tools/__tests__/tools-config.test.ts
@@ -13,6 +13,7 @@ const mindmap: FirstPartyTool = {
description: 'Test mindmap',
url: 'https://mindmap.example/',
scopes: ['openai:chat', 'doc:read'],
+ launchKind: 'room-oauth',
};
describe('mindmap tool registration', () => {
@@ -89,4 +90,35 @@ describe('mindmap tool registration', () => {
expect(calls[4]).toContain('?room=room_123');
expect(result).toEqual({ sharedDoc: true });
});
+
+ it('launches a direct tool without reading the document or calling the backend', async () => {
+ const getAccessToken = vi.fn();
+ const getDocContext = vi.fn();
+ const createRoom = vi.fn();
+ const completeLaunch = vi.fn();
+ const result = await launchFirstPartyTool(
+ {
+ ...mindmap,
+ id: 'direct',
+ url: 'https://direct.example/',
+ launchKind: 'direct',
+ },
+ {
+ getAccessToken,
+ getDocContext,
+ createRoom,
+ reserveLaunch: () => ({ kind: 'office' }),
+ completeLaunch,
+ cancelLaunch: vi.fn(),
+ },
+ );
+ expect(getAccessToken).not.toHaveBeenCalled();
+ expect(getDocContext).not.toHaveBeenCalled();
+ expect(createRoom).not.toHaveBeenCalled();
+ expect(completeLaunch).toHaveBeenCalledWith(
+ { kind: 'office' },
+ 'https://direct.example/',
+ );
+ expect(result).toEqual({ sharedDoc: false });
+ });
});
diff --git a/frontend/src/pages/tools/index.tsx b/frontend/src/pages/tools/index.tsx
index d5ca3e7b..ddd303e8 100644
--- a/frontend/src/pages/tools/index.tsx
+++ b/frontend/src/pages/tools/index.tsx
@@ -34,6 +34,8 @@ export interface FirstPartyTool {
/** Where the tool is hosted (its own origin). */
url: string;
scopes: ToolScope[];
+ /** Explicit launch protocol; room OAuth is not a generic tool requirement. */
+ launchKind: 'room-oauth' | 'direct';
}
/**
@@ -79,6 +81,7 @@ export const MINDMAP_TOOL: FirstPartyTool = {
'Explore your draft as a client-side mindmap. Opens in your browser with a read-only snapshot of your current document.',
url: MINDMAP_TOOL_URL,
scopes: ['openai:chat', 'doc:read'],
+ launchKind: 'room-oauth',
};
export const FIRST_PARTY_TOOLS: FirstPartyTool[] = MINDMAP_TOOL_ENABLED
@@ -100,14 +103,20 @@ export async function launchFirstPartyTool(
): Promise<{ sharedDoc: boolean }> {
const reservation = dependencies.reserveLaunch();
if (!reservation) {
- throw new Error('Your browser blocked the new window. Allow popups and try again.');
+ throw new Error(
+ 'Your browser blocked the new window. Allow popups and try again.',
+ );
}
try {
+ if (tool.launchKind === 'direct') {
+ dependencies.completeLaunch(reservation, tool.url);
+ return { sharedDoc: false };
+ }
const token = await dependencies.getAccessToken();
const doc = tool.scopes.includes('doc:read')
? await dependencies.getDocContext()
: undefined;
- if (!doc) throw new Error('Mindmap requires a document room.');
+ if (!doc) throw new Error(`${tool.name} requires a document room.`);
const room = await dependencies.createRoom(token, doc);
dependencies.completeLaunch(
reservation,
diff --git a/prototype-mindmap/src/PlatformBootstrap.tsx b/prototype-mindmap/src/PlatformBootstrap.tsx
index 8e6ad1fa..6b812ce7 100644
--- a/prototype-mindmap/src/PlatformBootstrap.tsx
+++ b/prototype-mindmap/src/PlatformBootstrap.tsx
@@ -322,9 +322,12 @@ function PlatformBootstrapContent() {
return;
}
if (callback) {
- void finishRoomAuthorization(window.location.search, storage.session)
+ const callbackSearch = window.location.search;
+ // Capture then remove code/state/error synchronously. Failed exchanges must
+ // not leave authorization material in browser history or copied URLs.
+ scrubOAuthFromUrl();
+ void finishRoomAuthorization(callbackSearch, storage.session)
.then((session) => {
- scrubOAuthFromUrl();
writePlatformSession(storage.session, session);
const saved = savedMindmapSummary(storage.local);
if (saved) setBoot({ kind: "choice", session, saved });
diff --git a/prototype-mindmap/src/platform-session.test.ts b/prototype-mindmap/src/platform-session.test.ts
index 5bc5f1b5..6a05eb98 100644
--- a/prototype-mindmap/src/platform-session.test.ts
+++ b/prototype-mindmap/src/platform-session.test.ts
@@ -1,13 +1,16 @@
import { describe, expect, it, vi } from "vitest";
import {
GrantExchangeError,
+ OAUTH_REQUEST_STORAGE_KEY,
PLATFORM_SESSION_STORAGE_KEY,
clearPlatformSession,
exchangeGrant,
+ finishRoomAuthorization,
grantFromHash,
hashWithoutGrant,
launchRequired,
readPlatformSession,
+ scrubOAuthFromUrl,
snapshotText,
writePlatformSession,
type PlatformSession,
@@ -142,6 +145,94 @@ describe("platform launcher session", () => {
expect(storage.getItem(PLATFORM_SESSION_STORAGE_KEY)).toBeNull();
});
+ it("accepts only legacy wtk tokens or compact JWTs from storage", () => {
+ const storage = memoryStorage();
+ const base = {
+ version: 1,
+ expiresAt: 1000,
+ scopes: ["openai:chat"],
+ doc: null,
+ capturedAt: 0,
+ };
+ storage.setItem(PLATFORM_SESSION_STORAGE_KEY, JSON.stringify({
+ ...base,
+ accessToken: "header.payload.signature",
+ }));
+ expect(readPlatformSession(storage)?.accessToken).toBe("header.payload.signature");
+ storage.setItem(PLATFORM_SESSION_STORAGE_KEY, JSON.stringify({
+ ...base,
+ accessToken: "this-is-not-a-token-even-if-long",
+ }));
+ expect(readPlatformSession(storage)).toBeNull();
+ });
+
+ it("rejects an OAuth token bound to a room other than the launched room", async () => {
+ const storage = memoryStorage();
+ storage.setItem(OAUTH_REQUEST_STORAGE_KEY, JSON.stringify({
+ state: "room_requested.random",
+ verifier: "verifier",
+ roomId: "room_requested",
+ clientId: "client",
+ redirectUri: "https://mindmap.example/",
+ }));
+ const fetcher = vi.fn().mockResolvedValue(response({
+ access_token: "header.payload.signature",
+ room_id: "room_other",
+ expires_in: 3600,
+ }));
+ await expect(finishRoomAuthorization(
+ "?code=code&state=room_requested.random",
+ storage,
+ { backendUrl: "https://writer.example/api", fetcher },
+ )).rejects.toMatchObject({ code: "invalid" });
+ expect(fetcher).toHaveBeenCalledTimes(1);
+ });
+
+ it("loads only the exact launched room after a successful OAuth exchange", async () => {
+ const storage = memoryStorage();
+ storage.setItem(OAUTH_REQUEST_STORAGE_KEY, JSON.stringify({
+ state: "room_requested.random",
+ verifier: "verifier",
+ roomId: "room_requested",
+ clientId: "client",
+ redirectUri: "https://mindmap.example/",
+ }));
+ const fetcher = vi.fn()
+ .mockResolvedValueOnce(response({
+ access_token: "header.payload.signature",
+ room_id: "room_requested",
+ expires_in: 60,
+ scope: "openai:chat doc:read",
+ }))
+ .mockResolvedValueOnce(response({
+ doc: { beforeCursor: "draft", selectedText: "", afterCursor: "" },
+ updated_at: 42,
+ }));
+ const session = await finishRoomAuthorization(
+ "?code=code&state=room_requested.random",
+ storage,
+ { backendUrl: "https://writer.example/api", fetcher, now: () => 1 },
+ );
+ expect(fetcher).toHaveBeenNthCalledWith(
+ 2,
+ "https://writer.example/api/rooms/room_requested",
+ expect.objectContaining({
+ credentials: "omit",
+ headers: { Authorization: "Bearer header.payload.signature" },
+ }),
+ );
+ expect(session).toMatchObject({ accessToken: "header.payload.signature", capturedAt: 42 });
+ });
+
+ it("scrubs OAuth callback parameters without depending on exchange success", () => {
+ const replaceState = vi.fn();
+ scrubOAuthFromUrl(
+ { pathname: "/mindmap" },
+ { state: { retained: true }, replaceState } as unknown as History,
+ );
+ expect(replaceState).toHaveBeenCalledWith({ retained: true }, "", "/mindmap");
+ });
+
it("maps expired and already-used grants to distinct errors", async () => {
for (const code of ["expired", "already_used"] as const) {
try {
diff --git a/prototype-mindmap/src/platform-session.ts b/prototype-mindmap/src/platform-session.ts
index 757a4dfe..3dbace19 100644
--- a/prototype-mindmap/src/platform-session.ts
+++ b/prototype-mindmap/src/platform-session.ts
@@ -3,7 +3,7 @@
export const PLATFORM_SESSION_STORAGE_KEY = "prototype-mindmap-platform-session-v1";
export const PLATFORM_SESSION_VERSION = 1;
const OAUTH_CLIENT_STORAGE_KEY = "prototype-mindmap-oauth-client-v1";
-const OAUTH_REQUEST_STORAGE_KEY = "prototype-mindmap-oauth-request-v1";
+export const OAUTH_REQUEST_STORAGE_KEY = "prototype-mindmap-oauth-request-v1";
const viteEnv = (import.meta as ImportMeta & { env?: Record }).env;
@@ -144,7 +144,7 @@ export function readPlatformSession(storage: Pick): Platform
if (
parsed.version !== PLATFORM_SESSION_VERSION ||
typeof parsed.accessToken !== "string" ||
- (!parsed.accessToken.startsWith("wtk_") && parsed.accessToken.length < 16) ||
+ !isPlatformAccessToken(parsed.accessToken) ||
typeof parsed.expiresAt !== "number" ||
!Number.isFinite(parsed.expiresAt) ||
typeof parsed.capturedAt !== "number" ||
@@ -174,6 +174,12 @@ export function readPlatformSession(storage: Pick): Platform
}
}
+/** Legacy handoff tokens use wtk_; OAuth access tokens are compact JWTs. */
+export function isPlatformAccessToken(token: string): boolean {
+ return /^wtk_[A-Za-z0-9_-]+$/.test(token) ||
+ /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/.test(token);
+}
+
interface OAuthRequestState {
state: string;
verifier: string;
@@ -278,7 +284,7 @@ export async function beginRoomAuthorization(
export async function finishRoomAuthorization(
search: string,
storage: Storage,
- options: { backendUrl?: string; now?: () => number } = {},
+ options: { backendUrl?: string; now?: () => number; fetcher?: typeof fetch } = {},
): Promise {
const params = new URLSearchParams(search);
const oauthError = params.get("error");
@@ -295,31 +301,55 @@ export async function finishRoomAuthorization(
}
storage.removeItem(OAUTH_REQUEST_STORAGE_KEY);
const backendUrl = options.backendUrl ?? PLATFORM_BACKEND_URL;
- const tokenResponse = await fetch(`${oauthBase(backendUrl)}/token`, {
- method: "POST",
- credentials: "omit",
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: new URLSearchParams({
- grant_type: "authorization_code",
- client_id: request.clientId,
- redirect_uri: request.redirectUri,
- code,
- code_verifier: request.verifier,
- resource: oauthResource(backendUrl),
- }),
- });
+ const fetcher = options.fetcher ?? fetch;
+ let tokenResponse: Response;
+ try {
+ tokenResponse = await fetcher(`${oauthBase(backendUrl)}/token`, {
+ method: "POST",
+ credentials: "omit",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: new URLSearchParams({
+ grant_type: "authorization_code",
+ client_id: request.clientId,
+ redirect_uri: request.redirectUri,
+ code,
+ code_verifier: request.verifier,
+ resource: oauthResource(backendUrl),
+ }),
+ });
+ } catch (error) {
+ throw new GrantExchangeError(
+ "network",
+ error instanceof Error ? error.message : "Could not reach Writing Tools.",
+ );
+ }
const token = await tokenResponse.json().catch(() => ({})) as Record;
- if (!tokenResponse.ok || typeof token.access_token !== "string") {
+ if (
+ !tokenResponse.ok ||
+ typeof token.access_token !== "string" ||
+ !isPlatformAccessToken(token.access_token)
+ ) {
throw new GrantExchangeError("invalid", typeof token.error_description === "string" ? token.error_description : "Token exchange failed.");
}
const roomId = typeof token.room_id === "string" && token.room_id.startsWith("room_")
? token.room_id
: null;
if (!roomId) throw new GrantExchangeError("invalid", "The token was not bound to a room.");
- const roomResponse = await fetch(`${backendUrl.replace(/\/$/, "")}/rooms/${encodeURIComponent(roomId)}`, {
- credentials: "omit",
- headers: { Authorization: `Bearer ${token.access_token}` },
- });
+ if (roomId !== request.roomId) {
+ throw new GrantExchangeError("invalid", "The token was bound to a different room than this launch.");
+ }
+ let roomResponse: Response;
+ try {
+ roomResponse = await fetcher(`${backendUrl.replace(/\/$/, "")}/rooms/${encodeURIComponent(roomId)}`, {
+ credentials: "omit",
+ headers: { Authorization: `Bearer ${token.access_token}` },
+ });
+ } catch (error) {
+ throw new GrantExchangeError(
+ "network",
+ error instanceof Error ? error.message : "Could not reach Writing Tools.",
+ );
+ }
const room = await roomResponse.json().catch(() => ({})) as Record;
const doc = validDocContext(room.doc);
if (!roomResponse.ok || !doc) throw new GrantExchangeError("invalid", "The authorized room could not be loaded.");
@@ -335,8 +365,11 @@ export async function finishRoomAuthorization(
};
}
-export function scrubOAuthFromUrl(): void {
- window.history.replaceState(window.history.state, "", window.location.pathname);
+export function scrubOAuthFromUrl(
+ location: Pick = window.location,
+ history: Pick = window.history,
+): void {
+ history.replaceState(history.state, "", location.pathname);
}
export function writePlatformSession(
From 2612d969834264670e00a6bdc4c2174a78e25487 Mon Sep 17 00:00:00 2001
From: nhyiramante1
Date: Fri, 31 Jul 2026 13:14:06 -0400
Subject: [PATCH 3/4] feat: trust mindmap oauth room launch
---
backend/CLAUDE.md | 4 +-
backend/README.md | 3 +-
backend/src/__tests__/db.test.ts | 16 +-
.../oauth-room-middleware.integration.test.ts | 257 ++++++++++------
.../src/__tests__/oauth-room-resource.test.ts | 5 +-
backend/src/__tests__/rooms.test.ts | 37 +--
backend/src/app.ts | 10 +-
backend/src/auth.ts | 57 ++--
backend/src/config.ts | 19 ++
backend/src/db.ts | 6 +
backend/src/index.ts | 12 +
backend/src/migrate.ts | 3 +
backend/src/oauth-clients.ts | 68 +++++
backend/src/oauth-room-authorization.ts | 34 +--
backend/src/rooms.ts | 64 ----
backend/src/routes/oauth-pages.ts | 24 --
docker-compose.yml | 2 +
docs/oauth-rooms-pkce-poc.md | 48 ++-
docs/oauth-trusted-client-spec.md | 279 ++++++++++++++++++
frontend/src/pages/tools/index.tsx | 11 +-
prototype-mindmap/README.md | 5 +
.../src/platform-session.test.ts | 8 +
prototype-mindmap/src/platform-session.ts | 52 +---
scripts/get_env.py | 6 +
24 files changed, 679 insertions(+), 351 deletions(-)
create mode 100644 backend/src/oauth-clients.ts
create mode 100644 docs/oauth-trusted-client-spec.md
diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md
index 9c681a4d..446e617c 100644
--- a/backend/CLAUDE.md
+++ b/backend/CLAUDE.md
@@ -90,7 +90,9 @@ pays for sessionless requests — without it they're refused wherever auth is on
"delete my data" to purge a user's PostHog person; unset => that step no-ops), `LOG_DIR`
(overrides just the logs subdir). Auth: `BETTER_AUTH_ENABLED`, `BETTER_AUTH_SECRET`,
`BETTER_AUTH_URL`, `BETTER_AUTH_TRUSTED_ORIGINS`, `GOOGLE_CLIENT_ID`,
-`GOOGLE_CLIENT_SECRET`. For local dev, run `python scripts/get_env.py` to generate
+`GOOGLE_CLIENT_SECRET`, `MINDMAP_OAUTH_CLIENT_ID`, and
+`MINDMAP_OAUTH_REDIRECT_URIS` (comma-separated exact callback URLs). For local
+dev, run `python scripts/get_env.py` to generate
`backend/.env`; it prompts for `OPENAI_DEMO_API_KEY` too and defaults it to the main key,
since an unset demo key makes every demo/anonymous request 401 once auth is enabled.
diff --git a/backend/README.md b/backend/README.md
index 3b0e2d19..c714b204 100644
--- a/backend/README.md
+++ b/backend/README.md
@@ -46,4 +46,5 @@ key for it locally),
for `app.db` + `logs/`), `PORT` (default 8000), `DEBUG`, `POSTHOG_PROJECT_TOKEN`,
`POSTHOG_HOST`, `LOG_DIR` (overrides just the logs subdir). Auth (Better Auth) adds
`BETTER_AUTH_ENABLED`, `BETTER_AUTH_SECRET`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`,
-and related vars — see [CLAUDE.md](CLAUDE.md) for the full list.
+`MINDMAP_OAUTH_CLIENT_ID`, `MINDMAP_OAUTH_REDIRECT_URIS`, and related vars — see
+[CLAUDE.md](CLAUDE.md) for the full list.
diff --git a/backend/src/__tests__/db.test.ts b/backend/src/__tests__/db.test.ts
index 99242f6e..14182498 100644
--- a/backend/src/__tests__/db.test.ts
+++ b/backend/src/__tests__/db.test.ts
@@ -22,7 +22,7 @@ afterEach(() => {
describe('migrations', () => {
it('creates our schema and records the version', () => {
const conn = db();
- expect(conn.pragma('user_version', { simple: true })).toBe(5);
+ expect(conn.pragma('user_version', { simple: true })).toBe(6);
// The table is usable, not merely declared.
const table = conn
@@ -46,14 +46,10 @@ describe('migrations', () => {
.get();
expect(grantTable).toBeDefined();
- // v5 isolates room choices by OAuth authorization request.
- const selectionCols = conn
- .prepare(`PRAGMA table_info(oauth_room_selection)`)
- .all() as { name: string; pk: number }[];
- expect(selectionCols.find((c) => c.name === 'session_id')?.pk).toBe(1);
+ // v6 removes the now-unnecessary transient OAuth room selection.
expect(
- selectionCols.find((c) => c.name === 'authorization_state')?.pk,
- ).toBe(2);
+ conn.prepare(`SELECT name FROM sqlite_master WHERE name='oauth_room_selection'`).get(),
+ ).toBeUndefined();
});
it('is idempotent across reopens — a second open re-runs nothing', () => {
@@ -67,7 +63,7 @@ describe('migrations', () => {
// Reopening must not drop or recreate the table (CREATE TABLE would throw).
const conn = db();
- expect(conn.pragma('user_version', { simple: true })).toBe(5);
+ expect(conn.pragma('user_version', { simple: true })).toBe(6);
const rows = conn.prepare(`SELECT COUNT(*) AS n FROM llm_usage`).get() as {
n: number;
};
@@ -96,7 +92,7 @@ describe('legacy auth.db rename', () => {
expect(user).toEqual({ email: 'a@b.c' });
// ...and our migrations then run on top of the adopted database.
- expect(conn.pragma('user_version', { simple: true })).toBe(5);
+ expect(conn.pragma('user_version', { simple: true })).toBe(6);
});
it('leaves an existing app.db alone when a stray auth.db is also present', async () => {
diff --git a/backend/src/__tests__/oauth-room-middleware.integration.test.ts b/backend/src/__tests__/oauth-room-middleware.integration.test.ts
index fec49a32..7f82d310 100644
--- a/backend/src/__tests__/oauth-room-middleware.integration.test.ts
+++ b/backend/src/__tests__/oauth-room-middleware.integration.test.ts
@@ -1,13 +1,19 @@
+import { createHash } from 'node:crypto';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { getMigrations } from 'better-auth/db/migration';
+import { oauthProviderResourceClient } from '@better-auth/oauth-provider/resource-client';
import type { Auth } from '../auth.js';
-import { closeDb } from '../db.js';
+import { createApp } from '../app.js';
+import { closeDb, db } from '../db.js';
+import { provisionTrustedMindmapClient } from '../oauth-clients.js';
import { createRoom } from '../rooms.js';
const AUTH_BASE = 'http://localhost:8000/api/auth';
+const CLIENT_ID = 'integration-mindmap';
+const REDIRECT_URI = 'https://mindmap.example/';
let auth: Auth;
let dataDir: string;
@@ -18,137 +24,198 @@ function authRequest(pathname: string, init?: RequestInit): Promise {
function cookieHeader(response: Response): string {
const headers = response.headers as Headers & { getSetCookie?: () => string[] };
const values = headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? ''];
- return values
- .filter(Boolean)
- .map((value) => value.split(';', 1)[0])
- .join('; ');
+ return values.filter(Boolean).map((value) => value.split(';', 1)[0]).join('; ');
+}
+
+async function anonymousSession(): Promise<{ cookie: string; userId: string }> {
+ const response = await authRequest('/sign-in/anonymous', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Origin: 'http://localhost:8000' },
+ body: '{}',
+ });
+ expect(response.status).toBe(200);
+ const body = (await response.json()) as { user: { id: string } };
+ return { cookie: cookieHeader(response), userId: body.user.id };
+}
+
+function authorizeUrl(roomId: string, challenge: string, clientId = CLIENT_ID): URL {
+ const url = new URL(`${AUTH_BASE}/oauth2/authorize`);
+ url.search = new URLSearchParams({
+ client_id: clientId,
+ redirect_uri: REDIRECT_URI,
+ response_type: 'code',
+ scope: 'openai:chat doc:read',
+ resource: 'http://localhost:8000',
+ state: `${roomId}.random-csrf`,
+ code_challenge: challenge,
+ code_challenge_method: 'S256',
+ }).toString();
+ return url;
}
beforeAll(async () => {
- dataDir = mkdtempSync(path.join(tmpdir(), 'writing-tools-oauth-middleware-'));
+ dataDir = mkdtempSync(path.join(tmpdir(), 'writing-tools-oauth-integration-'));
process.env.DATA_DIR = dataDir;
process.env.BETTER_AUTH_SECRET = 'integration-secret-that-is-at-least-32-characters';
process.env.BETTER_AUTH_URL = 'http://localhost:8000';
process.env.GOOGLE_CLIENT_ID = 'test-client';
process.env.GOOGLE_CLIENT_SECRET = 'test-secret';
+ process.env.MINDMAP_OAUTH_CLIENT_ID = CLIENT_ID;
+ process.env.MINDMAP_OAUTH_REDIRECT_URIS = REDIRECT_URI;
+ process.env.OPENAI_API_KEY = 'test-openai-key';
+ process.env.OPENAI_DEMO_API_KEY = 'test-demo-openai-key';
({ auth } = await import('../auth.js'));
const { runMigrations } = await getMigrations(auth.options);
await runMigrations();
+ db().prepare(
+ `INSERT INTO oauthClient (id, clientId, redirectUris)
+ VALUES ('stale-row', 'stale-dynamic-client', '["https://stale.example/"]')`,
+ ).run();
+ provisionTrustedMindmapClient();
});
afterAll(() => {
- vi.useRealTimers();
+ vi.unstubAllGlobals();
closeDb();
rmSync(dataDir, { recursive: true, force: true });
});
-describe('signed oauth_query middleware coupling', () => {
- it('populates verified state, gates client metadata, and rejects tampering or expiry', async () => {
- const registration = await authRequest('/oauth2/register', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- redirect_uris: ['https://mindmap.example/callback'],
- client_name: 'Integration Mindmap',
- client_uri: 'https://mindmap.example',
- token_endpoint_auth_method: 'none',
- grant_types: ['authorization_code'],
- response_types: ['code'],
- scope: 'openai:chat doc:read',
- type: 'user-agent-based',
- }),
- });
- expect(registration.status).toBe(200);
- const registered = (await registration.json()) as { client_id: string };
-
- const signIn = await authRequest('/sign-in/anonymous', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Origin: 'http://localhost:8000',
- },
- body: '{}',
- });
- expect(signIn.status).toBe(200);
- const signedIn = (await signIn.json()) as { user: { id: string } };
- const cookie = cookieHeader(signIn);
- expect(cookie).toContain('session_token=');
+describe('trusted Mindmap OAuth launch', () => {
+ it('keeps only the configured trusted client during idempotent provisioning', () => {
+ provisionTrustedMindmapClient();
+ const clients = db().prepare(
+ `SELECT clientId, skipConsent, requirePKCE FROM oauthClient`,
+ ).all();
+ expect(clients).toEqual([{
+ clientId: CLIENT_ID,
+ skipConsent: 1,
+ requirePKCE: 1,
+ }]);
+ });
- const room = createRoom(signedIn.user.id, 'Signed query draft', {
+ it('issues a real room-bound token with no consent or room screen', async () => {
+ const { cookie, userId } = await anonymousSession();
+ const room = createRoom(userId, 'Signed query draft', {
beforeCursor: 'private', selectedText: '', afterCursor: '',
});
- vi.useFakeTimers();
- vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
- const authorize = new URL(`${AUTH_BASE}/oauth2/authorize`);
- authorize.search = new URLSearchParams({
- client_id: registered.client_id,
- redirect_uri: 'https://mindmap.example/callback',
- response_type: 'code',
- scope: 'openai:chat doc:read',
- state: `${room.id}.random-csrf`,
- code_challenge: 'a'.repeat(43),
- code_challenge_method: 'S256',
- }).toString();
+ const other = createRoom(userId, 'Other draft', {
+ beforeCursor: 'other', selectedText: '', afterCursor: '',
+ });
+ const verifier = 'mindmap-pkce-verifier-that-is-long-enough-for-the-test-123456';
+ const challenge = createHash('sha256').update(verifier).digest('base64url');
+
const authorization = await auth.handler(
- new Request(authorize, { headers: { Cookie: cookie } }),
+ new Request(authorizeUrl(room.id, challenge), { headers: { Cookie: cookie } }),
);
expect(authorization.status).toBe(302);
- const roomPage = new URL(
- authorization.headers.get('location') ?? '',
- AUTH_BASE,
- );
- expect(roomPage.pathname).toBe('/api/oauth/room');
- const oauthQuery = roomPage.search.slice(1);
- expect(oauthQuery).toContain('sig=');
+ const callback = new URL(authorization.headers.get('location') ?? '');
+ expect(callback.origin + callback.pathname).toBe(REDIRECT_URI);
+ expect(callback.pathname).not.toBe('/api/oauth/room');
+ expect(callback.pathname).not.toBe('/api/oauth/consent');
+ const code = callback.searchParams.get('code');
+ expect(code).toBeTruthy();
- const context = await authRequest('/oauth2/room-context', {
+ const tokenResponse = await authRequest('/oauth2/token', {
method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Cookie: cookie,
- Origin: 'http://localhost:8000',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ grant_type: 'authorization_code', client_id: CLIENT_ID,
+ redirect_uri: REDIRECT_URI, code: code!, code_verifier: verifier,
+ resource: 'http://localhost:8000',
+ }),
+ });
+ expect(tokenResponse.status).toBe(200);
+ const token = (await tokenResponse.json()) as {
+ access_token: string; room_id: string;
+ };
+ expect(token.room_id).toBe(room.id);
+ const payload = JSON.parse(
+ Buffer.from(token.access_token.split('.')[1]!, 'base64url').toString(),
+ ) as Record;
+ expect(payload.room_id).toBe(room.id);
+ const jwks = await authRequest('/jwks').then((response) => response.json()) as {
+ keys: Array>;
+ };
+ expect(jwks).toMatchObject({ keys: expect.any(Array) });
+ const providerVerifier = oauthProviderResourceClient(auth).getActions().verifyAccessToken;
+ const verifyOAuthAccessToken = (
+ accessToken: string,
+ options: {
+ verifyOptions: { audience: string; issuer: string };
+ scopes: string[];
},
- body: JSON.stringify({ oauth_query: oauthQuery }),
+ ) => providerVerifier(accessToken, {
+ ...options,
+ // The production verifier fetches this endpoint over HTTP. Supplying the
+ // same real JWKS as a function keeps this integration test in-process.
+ jwksUrl: (async () => jwks) as unknown as string,
});
- expect(context.status).toBe(200);
- expect(await context.json()).toMatchObject({
- room: { id: room.id, name: 'Signed query draft' },
- client: {
- id: registered.client_id,
- name: 'Integration Mindmap',
- redirect_origin: 'https://mindmap.example',
+
+ vi.stubGlobal('fetch', vi.fn(async () =>
+ new Response(JSON.stringify({
+ id: 'chatcmpl_test', model: 'gpt-4o', choices: [],
+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } }),
+ ));
+ await expect(verifyOAuthAccessToken(
+ token.access_token,
+ {
+ verifyOptions: {
+ audience: 'http://localhost:8000',
+ issuer: 'http://localhost:8000/api/auth',
+ },
+ scopes: ['doc:read'],
},
- selected: false,
+ )).resolves.toMatchObject({ room_id: room.id });
+ const app = createApp({ auth, verifyOAuthAccessToken });
+ const granted = await app.request(`/api/rooms/${room.id}`, {
+ headers: { Authorization: `Bearer ${token.access_token}` },
});
+ expect(granted.status).toBe(200);
+ expect(await granted.json()).toMatchObject({ id: room.id });
- const tampered = new URLSearchParams(oauthQuery);
- tampered.set('state', 'room_attacker.random-csrf');
- const tamperedResponse = await authRequest('/oauth2/room-context', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', Cookie: cookie },
- body: JSON.stringify({ oauth_query: tampered.toString() }),
+ const denied = await app.request(`/api/rooms/${other.id}`, {
+ headers: { Authorization: `Bearer ${token.access_token}` },
});
- expect(tamperedResponse.status).toBe(400);
+ expect(denied.status).toBe(403);
- vi.setSystemTime(new Date('2026-01-01T00:11:00Z'));
- const expired = await authRequest('/oauth2/room-context', {
+ const proxy = await app.request('/api/openai/chat/completions', {
method: 'POST',
- headers: { 'Content-Type': 'application/json', Cookie: cookie },
- body: JSON.stringify({ oauth_query: oauthQuery }),
+ headers: {
+ Authorization: `Bearer ${token.access_token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ model: 'gpt-4o', messages: [] }),
});
- expect(expired.status).toBe(400);
+ expect(proxy.status).toBe(200);
+ });
- vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
- const authContext = await auth.$context;
- await authContext.adapter.delete({
- model: 'oauthClient',
- where: [{ field: 'clientId', value: registered.client_id }],
+ it('refuses registration, unknown clients, and rooms owned by another user', async () => {
+ const registration = await authRequest('/oauth2/register', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ redirect_uris: [REDIRECT_URI] }),
});
- const missingClient = await authRequest('/oauth2/room-context', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', Cookie: cookie },
- body: JSON.stringify({ oauth_query: oauthQuery }),
+ expect(registration.status).toBe(403);
+
+ const owner = await anonymousSession();
+ const visitor = await anonymousSession();
+ const room = createRoom(owner.userId, 'Owner only', {
+ beforeCursor: 'private', selectedText: '', afterCursor: '',
});
- expect(missingClient.status).toBe(400);
+ const challenge = createHash('sha256').update('v'.repeat(64)).digest('base64url');
+ const unknown = await auth.handler(new Request(
+ authorizeUrl(room.id, challenge, 'unregistered-client'),
+ { headers: { Cookie: visitor.cookie } },
+ ));
+ const unknownLocation = new URL(unknown.headers.get('location') ?? REDIRECT_URI);
+ expect(unknownLocation.searchParams.get('code')).toBeNull();
+ expect(unknownLocation.searchParams.get('error')).toBeTruthy();
+
+ const foreignRoom = await auth.handler(new Request(
+ authorizeUrl(room.id, challenge),
+ { headers: { Cookie: visitor.cookie } },
+ ));
+ const location = foreignRoom.headers.get('location') ?? '';
+ expect(location).not.toContain(`${REDIRECT_URI}?code=`);
});
});
diff --git a/backend/src/__tests__/oauth-room-resource.test.ts b/backend/src/__tests__/oauth-room-resource.test.ts
index ae4c6d7f..9cdab3a2 100644
--- a/backend/src/__tests__/oauth-room-resource.test.ts
+++ b/backend/src/__tests__/oauth-room-resource.test.ts
@@ -40,7 +40,10 @@ describe('GET /api/rooms/:roomId', () => {
expect(verifyOAuthAccessToken).toHaveBeenCalledWith(
'header.payload.signature',
{
- verifyOptions: { audience: expect.any(String) },
+ verifyOptions: {
+ audience: expect.any(String),
+ issuer: expect.any(String),
+ },
scopes: ['doc:read'],
},
);
diff --git a/backend/src/__tests__/rooms.test.ts b/backend/src/__tests__/rooms.test.ts
index e11ef518..637e1667 100644
--- a/backend/src/__tests__/rooms.test.ts
+++ b/backend/src/__tests__/rooms.test.ts
@@ -5,12 +5,9 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { closeDb } from '../db.js';
import {
createRoom,
- consumeRoomSelection,
deleteRoomsForUser,
getRoomForUser,
listRooms,
- selectRoomForOAuth,
- selectedRoomForOAuth,
} from '../rooms.js';
let dir: string;
@@ -38,42 +35,12 @@ describe('rooms', () => {
expect(getRoomForUser(room.id, 'other')).toBeNull();
});
- it('binds an OAuth request only after checking room ownership', () => {
- const room = createRoom('owner', 'Draft', {
- beforeCursor: '',
- selectedText: 'draft',
- afterCursor: '',
- });
- expect(selectRoomForOAuth('session', 'state-a', 'other', room.id)).toBe(false);
- expect(selectedRoomForOAuth('session', 'state-a', 'other')).toBeUndefined();
- expect(selectRoomForOAuth('session', 'state-a', 'owner', room.id)).toBe(true);
- expect(selectedRoomForOAuth('session', 'state-a', 'owner')).toBe(room.id);
- expect(selectedRoomForOAuth('session', 'state-a', 'other')).toBeUndefined();
- });
-
- it('isolates concurrent OAuth authorizations and consumes only the exchanged one', () => {
- const first = createRoom('owner', 'First', {
- beforeCursor: '', selectedText: 'first', afterCursor: '',
- });
- const second = createRoom('owner', 'Second', {
- beforeCursor: '', selectedText: 'second', afterCursor: '',
- });
- expect(selectRoomForOAuth('session', 'state-a', 'owner', first.id)).toBe(true);
- expect(selectRoomForOAuth('session', 'state-b', 'owner', second.id)).toBe(true);
- expect(selectedRoomForOAuth('session', 'state-a', 'owner')).toBe(first.id);
- expect(selectedRoomForOAuth('session', 'state-b', 'owner')).toBe(second.id);
- consumeRoomSelection('session', 'state-a');
- expect(selectedRoomForOAuth('session', 'state-a', 'owner')).toBeUndefined();
- expect(selectedRoomForOAuth('session', 'state-b', 'owner')).toBe(second.id);
- });
-
- it('deletes durable rooms and their pending OAuth selections for erasure', async () => {
+ it('deletes durable rooms for erasure', async () => {
const room = createRoom('owner', 'Private draft', {
beforeCursor: 'private', selectedText: '', afterCursor: '',
});
- expect(selectRoomForOAuth('session', 'state', 'owner', room.id)).toBe(true);
await deleteRoomsForUser('owner');
expect(listRooms('owner')).toEqual([]);
- expect(selectedRoomForOAuth('session', 'state', 'owner')).toBeUndefined();
+ expect(getRoomForUser(room.id, 'owner')).toBeNull();
});
});
diff --git a/backend/src/app.ts b/backend/src/app.ts
index 33250389..2ecb66a2 100644
--- a/backend/src/app.ts
+++ b/backend/src/app.ts
@@ -80,7 +80,10 @@ function logSecretGate(c: Context, provided: string): Response | null {
type OAuthAccessTokenVerifier = (
token: string,
- options: { verifyOptions: { audience: string }; scopes: string[] },
+ options: {
+ verifyOptions: { audience: string; issuer: string };
+ scopes: string[];
+ },
) => Promise>;
export function createApp({
@@ -97,6 +100,7 @@ export function createApp({
(auth?.options
? oauthProviderResourceClient(auth).getActions().verifyAccessToken
: null);
+ const oauthIssuer = `${betterAuthUrl().replace(/\/$/, '')}/api/auth`;
// CORS stays fully permissive for now to preserve existing behaviour.
app.use('*', cors({ exposeHeaders: [PLATFORM_AUTH_ERROR_HEADER] }));
@@ -219,7 +223,7 @@ export function createApp({
if (bearer && verifyOAuthAccessToken) {
try {
const claims = await verifyOAuthAccessToken(bearer, {
- verifyOptions: { audience: betterAuthUrl() },
+ verifyOptions: { audience: betterAuthUrl(), issuer: oauthIssuer },
scopes: ['openai:chat'],
});
if (typeof claims.sub !== 'string') return null;
@@ -320,7 +324,7 @@ export function createApp({
}
try {
const claims = await verifyOAuthAccessToken(token, {
- verifyOptions: { audience: betterAuthUrl() },
+ verifyOptions: { audience: betterAuthUrl(), issuer: oauthIssuer },
scopes: ['doc:read'],
});
const roomId = c.req.param('roomId');
diff --git a/backend/src/auth.ts b/backend/src/auth.ts
index 38d39e41..cd54caaa 100644
--- a/backend/src/auth.ts
+++ b/backend/src/auth.ts
@@ -7,6 +7,7 @@ import {
deviceAuthorization,
jwt,
} from 'better-auth/plugins';
+import { APIError } from 'better-auth/api';
import {
betterAuthSecret,
betterAuthTrustedOrigins,
@@ -14,6 +15,7 @@ import {
deviceClientIds,
googleClientId,
googleClientSecret,
+ mindmapOAuthClientId,
} from './config.js';
import {
type ConsentLevel,
@@ -25,15 +27,29 @@ import { db } from './db.js';
import { eraseAccountData } from './erasure.js';
import { anonymizeUserUsage } from './usage.js';
import { isUserAllowed } from './userAllowlist.js';
-import {
- consumeRoomSelection,
- selectedRoomForOAuth,
-} from './rooms.js';
+import { getRoomForUser } from './rooms.js';
import {
currentOAuthAuthorization,
roomOAuthAuthorization,
} from './oauth-room-authorization.js';
+const roomConsentReferenceId = async ({
+ user,
+ scopes,
+}: {
+ user: { id: string };
+ scopes: readonly string[];
+}): Promise => {
+ if (!scopes.includes('doc:read')) return undefined;
+ const authorization = await currentOAuthAuthorization();
+ if (!getRoomForUser(authorization.roomId, user.id)) {
+ throw new APIError('FORBIDDEN', {
+ message: 'The launched room does not belong to this account.',
+ });
+ }
+ return authorization.roomId;
+};
+
// A module-level `auth` singleton (not a factory) so the Better Auth CLI can
// auto-discover it: `npx @better-auth/cli migrate` looks for an exported `auth`
// instance in src/auth.ts. app.ts imports the TYPE only, so importing app.ts in
@@ -127,38 +143,23 @@ export const auth = betterAuth({
scopes: ['openai:chat', 'doc:read'],
validAudiences: [betterAuthUrl()],
grantTypes: ['authorization_code'],
- allowDynamicClientRegistration: true,
- allowUnauthenticatedClientRegistration: true,
+ allowDynamicClientRegistration: false,
+ allowUnauthenticatedClientRegistration: false,
+ cachedTrustedClients: new Set([mindmapOAuthClientId()]),
accessTokenExpiresIn: 60 * 60,
+ // Better Auth 1.6.22 calls shouldRedirect unconditionally whenever postLogin
+ // is present. This constant-false compatibility hook does not implement a
+ // selection step; `page` is consequently unreachable.
postLogin: {
- page: '/api/oauth/room',
- shouldRedirect: async ({ user, session }) => {
- const authorization = await currentOAuthAuthorization();
- return !selectedRoomForOAuth(
- session.id,
- authorization.state,
- user.id,
- );
- },
- consentReferenceId: async ({ user, session, scopes }) => {
- if (!scopes.includes('doc:read')) return undefined;
- const authorization = await currentOAuthAuthorization();
- const roomId = selectedRoomForOAuth(
- session.id,
- authorization.state,
- user.id,
- );
- if (!roomId) throw new Error('A room must be selected for doc:read.');
- return roomId;
- },
+ page: '/api/oauth/login',
+ shouldRedirect: () => false,
+ consentReferenceId: roomConsentReferenceId,
},
customAccessTokenClaims: ({ referenceId }) => ({
...(referenceId ? { room_id: referenceId } : {}),
}),
customTokenResponseFields: ({ verificationValue }) => {
if (!verificationValue?.referenceId) return {};
- const state = verificationValue.query.state;
- if (state) consumeRoomSelection(verificationValue.sessionId, state);
return { room_id: verificationValue.referenceId };
},
}),
diff --git a/backend/src/config.ts b/backend/src/config.ts
index 4d72bce2..52665b91 100644
--- a/backend/src/config.ts
+++ b/backend/src/config.ts
@@ -59,6 +59,25 @@ export const googleClientId = () => (process.env.GOOGLE_CLIENT_ID ?? '').trim();
export const googleClientSecret = () =>
(process.env.GOOGLE_CLIENT_SECRET ?? '').trim();
+// Fixed public OAuth client used by the separately hosted Mindmap. The client id
+// is an identifier, not a secret. Production must provide the exact deployed
+// callback URL; local development defaults to Vite's Mindmap origin.
+export const mindmapOAuthClientId = () => {
+ const configured = (process.env.MINDMAP_OAUTH_CLIENT_ID ?? '').trim();
+ if (configured) return configured;
+ if ((process.env.NODE_ENV ?? '').toLowerCase() === 'production') return '';
+ return 'writing-tools-mindmap';
+};
+export const mindmapOAuthRedirectUris = (): string[] => {
+ const configured = (process.env.MINDMAP_OAUTH_REDIRECT_URIS ?? '')
+ .split(',')
+ .map((value) => value.trim())
+ .filter(Boolean);
+ if (configured.length > 0) return configured;
+ if ((process.env.NODE_ENV ?? '').toLowerCase() === 'production') return [];
+ return ['http://localhost:5181/'];
+};
+
// Comma-separated allowed device client IDs. An empty list rejects all requests.
export const deviceClientIds = (): string[] =>
(process.env.BETTER_AUTH_DEVICE_CLIENT_IDS ?? '')
diff --git a/backend/src/db.ts b/backend/src/db.ts
index 9f6b389c..c4c9562f 100644
--- a/backend/src/db.ts
+++ b/backend/src/db.ts
@@ -162,6 +162,12 @@ const MIGRATIONS: Array<(conn: Database.Database) => void> = [
CREATE INDEX oauth_room_selection_expiry ON oauth_room_selection (expires_at);
`);
},
+ // v6 — the trusted Mindmap client binds a room directly from the verified OAuth
+ // request and checks ownership server-side, so no transient human selection is
+ // stored anymore. Appending a drop migration preserves deployed v5 databases.
+ (conn) => {
+ conn.exec(`DROP TABLE oauth_room_selection;`);
+ },
];
function migrate(conn: Database.Database): void {
diff --git a/backend/src/index.ts b/backend/src/index.ts
index 1b577d9d..c9ea44c6 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -7,6 +7,8 @@ import {
deviceClientIds,
googleClientId,
googleClientSecret,
+ mindmapOAuthClientId,
+ mindmapOAuthRedirectUris,
openaiApiKey,
PORT,
} from './config.js';
@@ -36,11 +38,21 @@ if (authEnabled()) {
'BETTER_AUTH_DEVICE_CLIENT_IDS is empty — all device code requests will be rejected.',
);
}
+ if (!mindmapOAuthClientId() || mindmapOAuthRedirectUris().length === 0) {
+ console.error(
+ 'MINDMAP_OAUTH_CLIENT_ID and MINDMAP_OAUTH_REDIRECT_URIS are required when auth is enabled in production',
+ );
+ process.exit(1);
+ }
}
// Import the auth singleton only when enabled. The dynamic import means auth.ts
// (and its SQLite connection) is never executed when auth is disabled or in tests.
const auth = authEnabled() ? (await import('./auth.js')).auth : undefined;
+if (auth) {
+ const { provisionTrustedMindmapClient } = await import('./oauth-clients.js');
+ provisionTrustedMindmapClient();
+}
const app = createApp({ auth });
// if auth is enabled, register the device approval page route. This is separate from the main
diff --git a/backend/src/migrate.ts b/backend/src/migrate.ts
index 3c82e2bb..0b5faaae 100644
--- a/backend/src/migrate.ts
+++ b/backend/src/migrate.ts
@@ -15,6 +15,7 @@
import { getMigrations } from 'better-auth/db/migration';
import { auth } from './auth.js';
import { db } from './db.js';
+import { provisionTrustedMindmapClient } from './oauth-clients.js';
// Let the process exit naturally rather than calling process.exit(): in a
// container stdout is a pipe (async on Unix), so exiting immediately after a
@@ -28,6 +29,8 @@ try {
const { runMigrations } = await getMigrations(auth.options);
await runMigrations();
console.log('Better Auth migrations applied.');
+ provisionTrustedMindmapClient();
+ console.log('Trusted Mindmap OAuth client provisioned.');
} catch (err) {
console.error('Migration failed:', err);
process.exitCode = 1;
diff --git a/backend/src/oauth-clients.ts b/backend/src/oauth-clients.ts
new file mode 100644
index 00000000..4972e6ac
--- /dev/null
+++ b/backend/src/oauth-clients.ts
@@ -0,0 +1,68 @@
+import { db } from './db.js';
+import {
+ mindmapOAuthClientId,
+ mindmapOAuthRedirectUris,
+} from './config.js';
+
+/**
+ * Replace every previously dynamically registered OAuth client with the one
+ * configured first-party Mindmap client. Better Auth owns the table schema;
+ * this provisioning runs only after its migrations and is safe on every boot.
+ */
+export function provisionTrustedMindmapClient(): void {
+ const clientId = mindmapOAuthClientId();
+ const redirectUris = mindmapOAuthRedirectUris();
+ if (!clientId || redirectUris.length === 0) {
+ throw new Error(
+ 'MINDMAP_OAUTH_CLIENT_ID and MINDMAP_OAUTH_REDIRECT_URIS are required in production.',
+ );
+ }
+
+ for (const redirectUri of redirectUris) {
+ const parsed = new URL(redirectUri);
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
+ throw new Error(`Mindmap OAuth redirect URI must use HTTP(S): ${redirectUri}`);
+ }
+ }
+
+ const conn = db();
+ const now = new Date().toISOString();
+ conn.transaction(() => {
+ // Dynamic registration used random client ids. No other fixed OAuth clients
+ // exist today, so the configured Mindmap row is the sole survivor.
+ conn.prepare(`DELETE FROM oauthClient WHERE clientId <> ?`).run(clientId);
+ conn.prepare(
+ `INSERT INTO oauthClient
+ (id, clientId, disabled, skipConsent, scopes, createdAt, updatedAt,
+ name, uri, redirectUris, tokenEndpointAuthMethod, grantTypes,
+ responseTypes, public, type, requirePKCE)
+ VALUES (?, ?, 0, 1, ?, ?, ?, ?, ?, ?, 'none', ?, ?, 1,
+ 'user-agent-based', 1)
+ ON CONFLICT(clientId) DO UPDATE SET
+ disabled = 0,
+ skipConsent = 1,
+ scopes = excluded.scopes,
+ updatedAt = excluded.updatedAt,
+ name = excluded.name,
+ uri = excluded.uri,
+ redirectUris = excluded.redirectUris,
+ tokenEndpointAuthMethod = 'none',
+ grantTypes = excluded.grantTypes,
+ responseTypes = excluded.responseTypes,
+ public = 1,
+ type = 'user-agent-based',
+ requirePKCE = 1`,
+ ).run(
+ `trusted:${clientId}`,
+ clientId,
+ JSON.stringify(['openai:chat', 'doc:read']),
+ now,
+ now,
+ 'Writing Tools Mindmap',
+ new URL(redirectUris[0]!).origin,
+ JSON.stringify(redirectUris),
+ JSON.stringify(['authorization_code']),
+ JSON.stringify(['code']),
+ );
+ })();
+}
diff --git a/backend/src/oauth-room-authorization.ts b/backend/src/oauth-room-authorization.ts
index 0abe2339..49b99491 100644
--- a/backend/src/oauth-room-authorization.ts
+++ b/backend/src/oauth-room-authorization.ts
@@ -6,11 +6,7 @@ import {
sessionMiddleware,
} from 'better-auth/api';
import { z } from 'zod';
-import {
- getRoomForUser,
- selectRoomForOAuth,
- selectedRoomForOAuth,
-} from './rooms.js';
+import { getRoomForUser } from './rooms.js';
interface OAuthAuthorizationContext {
state: string;
@@ -90,37 +86,9 @@ export function roomOAuthAuthorization() {
name: client.name || client.clientId,
redirect_origin: new URL(authorization.redirectUri).origin,
},
- selected:
- selectedRoomForOAuth(
- session.session.id,
- authorization.state,
- session.user.id,
- ) === room.id,
});
},
),
- authorizeOAuthRoom: createAuthEndpoint(
- '/oauth2/authorize-room',
- { method: 'POST', body, use: [sessionMiddleware] },
- async (ctx) => {
- const session = await getSessionFromCtx(ctx);
- if (!session) throw new APIError('UNAUTHORIZED');
- const authorization = await currentOAuthAuthorization();
- if (
- !selectRoomForOAuth(
- session.session.id,
- authorization.state,
- session.user.id,
- authorization.roomId,
- )
- ) {
- throw new APIError('NOT_FOUND', {
- message: 'The launched room was not found for this account.',
- });
- }
- return ctx.json({ ok: true });
- },
- ),
},
} as const;
}
diff --git a/backend/src/rooms.ts b/backend/src/rooms.ts
index ddf713d8..e7fda7be 100644
--- a/backend/src/rooms.ts
+++ b/backend/src/rooms.ts
@@ -25,8 +25,6 @@ interface RoomRow {
updated_at: number;
}
-const ROOM_SELECTION_TTL_MS = 10 * 60 * 1000;
-
function parseRoom(row: RoomRow): Room {
return {
id: row.id,
@@ -81,68 +79,6 @@ export function getRoomForUser(roomId: string, userId: string): Room | null {
return row ? parseRoom(row) : null;
}
-export function selectRoomForOAuth(
- sessionId: string,
- authorizationState: string,
- userId: string,
- roomId: string,
-): boolean {
- if (!authorizationState) return false;
- if (!getRoomForUser(roomId, userId)) return false;
- const now = Date.now();
- db()
- .prepare(`DELETE FROM oauth_room_selection WHERE expires_at <= ?`)
- .run(now);
- db()
- .prepare(
- `INSERT INTO oauth_room_selection
- (session_id, authorization_state, user_id, room_id, expires_at)
- VALUES (?, ?, ?, ?, ?)
- ON CONFLICT(session_id, authorization_state) DO UPDATE SET
- user_id = excluded.user_id,
- room_id = excluded.room_id,
- expires_at = excluded.expires_at`,
- )
- .run(
- sessionId,
- authorizationState,
- userId,
- roomId,
- now + ROOM_SELECTION_TTL_MS,
- );
- return true;
-}
-
-export function selectedRoomForOAuth(
- sessionId: string,
- authorizationState: string,
- userId: string,
-): string | undefined {
- if (!authorizationState) return undefined;
- const row = db()
- .prepare(
- `SELECT room_id FROM oauth_room_selection
- WHERE session_id = ? AND authorization_state = ?
- AND user_id = ? AND expires_at > ?`,
- )
- .get(sessionId, authorizationState, userId, Date.now()) as
- | { room_id: string }
- | undefined;
- return row?.room_id;
-}
-
-export function consumeRoomSelection(
- sessionId: string,
- authorizationState: string,
-): void {
- db()
- .prepare(
- `DELETE FROM oauth_room_selection
- WHERE session_id = ? AND authorization_state = ?`,
- )
- .run(sessionId, authorizationState);
-}
-
export async function deleteRoomsForUser(userId: string): Promise {
db().prepare(`DELETE FROM room WHERE owner_user_id = ?`).run(userId);
}
diff --git a/backend/src/routes/oauth-pages.ts b/backend/src/routes/oauth-pages.ts
index 7910b08e..4999193b 100644
--- a/backend/src/routes/oauth-pages.ts
+++ b/backend/src/routes/oauth-pages.ts
@@ -35,35 +35,12 @@ async function start(){
start();`,
);
-const ROOM_HTML = shell(
- 'Authorize this room',
- `${helpers}
-async function start(){
- try{
- const context=await json('/api/auth/oauth2/room-context',{method:'POST',headers:{'Content-Type':'application/json'},body:oauthBody({})});
- const intro=el('p',context.client.name+' ('+context.client.redirect_origin+') wants access to the room opened from Writing Tools:');
- const room=el('p',context.room.name+' ('+context.room.id+')','muted');
- const button=el('button','Continue','primary');button.onclick=choose;
- show(intro,room,button);
- }catch(error){show(el('p',String(error),'error'))}
-}
-async function choose(){
- try{
- await json('/api/auth/oauth2/authorize-room',{method:'POST',headers:{'Content-Type':'application/json'},body:oauthBody({})});
- const result=await json('/api/auth/oauth2/continue',{method:'POST',headers:{'Content-Type':'application/json','Accept':'application/json'},body:oauthBody({postLogin:true})});
- go(result);
- }catch(error){show(el('p',String(error),'error'))}
-}
-start();`,
-);
-
const CONSENT_HTML = shell(
'Allow access?',
`${helpers}
async function start(){
try{
const context=await json('/api/auth/oauth2/room-context',{method:'POST',headers:{'Content-Type':'application/json'},body:oauthBody({})});
- if(!context.selected)throw new Error('This authorization has no selected room.');
const body=el('p',context.client.name+' ('+context.client.redirect_origin+') wants to read “'+context.room.name+'” and use Writing Tools AI on your behalf.');
const note=el('p','The access token is limited to this room and expires in one hour.','muted');
const actions=el('div',undefined,'actions');
@@ -83,6 +60,5 @@ start();`,
export function registerOAuthPages(app: import('hono').Hono): void {
app.get('/api/oauth/login', (c) => c.html(LOGIN_HTML));
- app.get('/api/oauth/room', (c) => c.html(ROOM_HTML));
app.get('/api/oauth/consent', (c) => c.html(CONSENT_HTML));
}
diff --git a/docker-compose.yml b/docker-compose.yml
index 0c7a248e..59269232 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -31,6 +31,8 @@ services:
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS:-}
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
- GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
+ - MINDMAP_OAUTH_CLIENT_ID=${MINDMAP_OAUTH_CLIENT_ID:-}
+ - MINDMAP_OAUTH_REDIRECT_URIS=${MINDMAP_OAUTH_REDIRECT_URIS:-}
- BETTER_AUTH_DEVICE_CLIENT_IDS=${BETTER_AUTH_DEVICE_CLIENT_IDS:-}
restart: unless-stopped
diff --git a/docs/oauth-rooms-pkce-poc.md b/docs/oauth-rooms-pkce-poc.md
index abc60b96..2faff740 100644
--- a/docs/oauth-rooms-pkce-poc.md
+++ b/docs/oauth-rooms-pkce-poc.md
@@ -25,8 +25,8 @@ Possession of it grants nothing.
1. The taskpane reads the current `DocContext` and sends it to `POST /api/rooms`
with its existing Better Auth session bearer.
2. The taskpane opens `https://mindmap…/?room=room_…`.
-3. Mindmap registers as an OAuth public browser client (cached in local storage),
- creates a random PKCE verifier, stores the verifier in session storage, and sends
+3. Mindmap uses its pre-registered trusted public client id, creates a random PKCE
+ verifier, stores the verifier in session storage, and sends
only its SHA-256 challenge to `/api/auth/oauth2/authorize`. The non-secret room
id is included in the standard `state` value to bind this authorization request
to the exact launched room; the complete state also contains random bytes and
@@ -34,11 +34,13 @@ Possession of it grants nothing.
4. The authorization server authenticates the writer in the system browser. This
may require Google sign-in because the Office taskpane and system browser do not
necessarily share cookies.
-5. Better Auth's `postLogin` hook sends the writer to a confirmation screen for
- the exact room named by the signed authorization request. The backend verifies
- that the signed-in user owns that room; there is no independent room picker.
-6. `consentReferenceId` returns the request-bound room id. The OAuth Provider plugin
- carries it through consent, authorization code, and access token.
+5. `consentReferenceId` derives the room from Mindmap's OAuth state and verifies
+ server-side that the signed-in user owns it. The room id is not signed
+ provenance; the boundary is the exact-redirect client, authenticated user,
+ ownership check, and room-bound signed token together.
+6. The trusted client has `skipConsent`, so an existing browser session returns
+ directly to Mindmap without a room-confirmation or consent screen. A user with
+ no browser session signs in first and then returns directly.
7. Mindmap receives the code at its registered redirect URI and exchanges it with
the locally retained verifier. The verifier never travels in the launcher URL.
8. Mindmap calls `GET /api/rooms/:roomId`. The resource server verifies the token's
@@ -63,16 +65,32 @@ Possession of it grants nothing.
- A room currently has one owner and one document snapshot. There is no membership
table, live synchronization, document update endpoint, or multi-document room.
-- Public dynamic client registration is enabled to keep the branch runnable without
- an out-of-band client provisioning step. Production should provision the known
- Mindmap client (or tightly rate-limit/validate registration) and disable open
- registration.
+- Dynamic and unauthenticated client registration are disabled. Startup
+ idempotently provisions the configured Mindmap client and removes stale dynamic
+ clients; it is the only OAuth client expected to survive that cleanup.
- Tokens expire after one hour; refresh tokens are not enabled.
-- A confirmed room authorization is keyed by both session and OAuth `state`,
- expires after ten minutes, and is consumed when the authorization code is
- exchanged. Concurrent authorization tabs cannot overwrite each other.
+- Removing the confirmation checkpoint is deliberate: an attacker would need both
+ an unguessable room id and the ability to drive its owner's browser. Fresh random
+ OAuth state still prevents callback mixups and CSRF.
- Deleting logged activity preserves active rooms. Account deletion removes room
- snapshots and their pending authorization rows.
+ snapshots.
- This is bearer-token security. PKCE prevents interception of the authorization
code; it does not make a stolen access token unusable. Sender-constrained tokens
(for example DPoP) would be a separate layer.
+
+## Trusted-client implementation findings
+
+In installed `@better-auth/oauth-provider` 1.6.22, `consentReferenceId` runs before
+the `client.skipConsent` branch and its result is stored on the authorization-code
+verification value. The resulting `referenceId` reaches both the custom access-token
+claim and token response. The no-screen integration test exercises that behavior
+with a real signed token, room-resource request, and OpenAI-proxy request.
+
+Better Auth 1.6.22 calls `postLogin.shouldRedirect` whenever a `postLogin` object
+exists. The configuration therefore retains a constant-false compatibility hook
+and unreachable page value; there is no room-selection or confirmation machinery.
+
+An appended v6 application migration drops `oauth_room_selection`, preserving
+upgrades from existing v5 databases. The final production hostname—and therefore
+the exact production redirect URI and matching build-time values—remains the sole
+deployment configuration decision.
diff --git a/docs/oauth-trusted-client-spec.md b/docs/oauth-trusted-client-spec.md
new file mode 100644
index 00000000..a6fc726d
--- /dev/null
+++ b/docs/oauth-trusted-client-spec.md
@@ -0,0 +1,279 @@
+# Spec — trusted Mindmap client, no-screen launch, end-to-end test
+
+Target branch: `agent/oauth-rooms-pkce-poc` (PR #594, draft).
+Base: commit `d3053b8c`, all 11 CI checks green.
+Companion doc: `docs/oauth-rooms-pkce-poc.md` (update it as part of this work).
+
+Implementation status (2026-07-31): implemented and validated locally. Task 0
+confirmed that Better Auth 1.6.22 calls `consentReferenceId` before its
+`skipConsent` branch. The only remaining deployment input is the final production
+Mindmap hostname/exact redirect URI. Nothing from this pass has been pushed.
+
+This spec covers three changes that are **one logical change**: making Mindmap a
+pre-registered trusted OAuth client, deleting the room-confirmation machinery
+that a trusted client makes unnecessary, and pinning the resulting no-screen
+flow with a real end-to-end test.
+
+---
+
+## Why
+
+Two review rounds established:
+
+1. **Open dynamic client registration is the one genuine merge blocker.**
+ `allowDynamicClientRegistration` and `allowUnauthenticatedClientRegistration`
+ are both `true` in `backend/src/auth.ts`. Anyone can register a client
+ against a production authorization server.
+
+2. **Both per-launch screens are our design, not PKCE's**, and they have
+ *different* causes — fixing one does not fix the other:
+
+ | Screen | Gated by | Cause |
+ |---|---|---|
+ | `/api/oauth/room` | `postLogin.shouldRedirect` → selection keyed by `(session, state)` | fresh `state` every launch ⇒ never a match |
+ | `/api/oauth/consent` | Better Auth remembered consent | keyed on `clientId + userId + referenceId`; `referenceId` is the room, new every launch ⇒ never a match |
+
+ Verified in `@better-auth/oauth-provider@1.6.22`: the consent lookup at
+ `dist/index.mjs` (~line 58) is
+ `where: [clientId, userId, ...(referenceId ? [referenceId] : [])]`, and
+ `skipConsent` exists as a client field (`dist/oauth-D74mBkw6.d.mts:1237`,
+ schema at `:25`).
+
+A trusted client with `skipConsent` fixes the consent screen. Deleting the
+confirm page fixes the other. Together they give: existing browser session →
+brief redirect, no screens; no session → sign in, then straight back.
+
+---
+
+## The security boundary (state it this way)
+
+Do **not** describe the room as coming from "the signed authorization request."
+The room id originates in Mindmap-generated OAuth `state` and is not signed by
+the taskpane — any party can put any value there. Better Auth integrity-protects
+the *continuation* (`oauth_query`, the `sig=` parameter), not the provenance of
+`state`.
+
+What actually holds the flow together is four things in conjunction:
+
+1. a fixed trusted client with an **exact** registered redirect URI,
+2. an authenticated user,
+3. **server-side verification that the room belongs to that user**,
+4. a room-bound signed access token, re-checked at the resource.
+
+Removing the confirmation page removes the last human checkpoint, so (3) plus
+room-id unguessability (`room_` + 18 random bytes) carries the weight. That is
+an acceptable trade — an attacker must already know the victim's room id *and*
+drive the victim's browser — but record it as a decision in
+`docs/oauth-rooms-pkce-poc.md`, not as a silent side effect of a UX cleanup.
+
+---
+
+## Task 0 — verify the load-bearing assumption FIRST
+
+**Do not build on this until it is confirmed.** The entire design assumes the
+room id still reaches the access token when consent is skipped.
+
+Today the chain is:
+
+```
+postLogin.consentReferenceId → referenceId
+ → customAccessTokenClaims({ referenceId }) → room_id claim
+ → customTokenResponseFields → room_id in the token response
+```
+
+`consentReferenceId` is a **consent-time** hook. If `skipConsent: true` bypasses
+it, `referenceId` is never set, `room_id` never reaches the token, and
+`finishRoomAuthorization` fails its own `roomId !== request.roomId` check.
+
+**Determine, from the installed 1.6.22 source, whether `consentReferenceId`
+still fires for a client with `skipConsent: true`.** Write a throwaway probe if
+reading the source is ambiguous.
+
+- **If it fires** — proceed as specified below.
+- **If it does not** — stop and report. Do not invent a workaround; the
+ alternative (deriving the room inside `customAccessTokenClaims` from the
+ authorization query, with the ownership check moved there) changes where the
+ security boundary sits and needs review before implementation.
+
+Record the finding in the PR description either way.
+
+---
+
+## Task 1 — trusted fixed client
+
+### Backend (`backend/src/auth.ts`)
+
+- Set `allowDynamicClientRegistration: false` and
+ `allowUnauthenticatedClientRegistration: false`.
+- Seed one Mindmap client at startup (idempotent — safe to run on every boot):
+ - client id from config, not hardcoded; expose as an env var alongside the
+ existing Better Auth config in `backend/src/config.ts`
+ - `token_endpoint_auth_method: "none"` (public client)
+ - `grant_types: ["authorization_code"]`, `response_types: ["code"]`
+ - scopes `openai:chat doc:read`
+ - **exact** `redirect_uris` — no wildcards, no prefix matching
+ - `skipConsent: true`
+- **`skipConsent` must apply to this client only.** Never make it a global
+ option, and never grant it to a dynamically registered client.
+
+### Purge stale clients
+
+Disabling registration prevents *new* registrations; it does **not** revoke
+clients already stored. Codex's own smoke runs have created some. Add a
+deliberate cleanup (a migration or a documented one-off) that removes
+dynamically registered `oauthClient` rows, and say in the doc which rows are
+expected to survive.
+
+### Mindmap (`prototype-mindmap/src/platform-session.ts`)
+
+- Delete the `clientId()` self-registration path entirely: the
+ `POST /oauth2/register` call and the `OAUTH_CLIENT_STORAGE_KEY` localStorage
+ cache (~lines 220–255).
+- Read a build-time `VITE_OAUTH_CLIENT_ID` instead. Fail closed in production if
+ it is missing, matching how `VITE_BACKEND_URL` is handled at ~line 21.
+- A public OAuth client id in the compiled bundle is **fine** — it is an
+ identifier, not a secret. Do not add a secret-handling path for it.
+
+### Redirect URI
+
+`callbackUri()` returns `window.location.origin + window.location.pathname`.
+The registered value must match exactly. **The production hostname is not yet
+decided** (`mindmap.thoughtful-ai.com` vs a `prototypes.*` umbrella), so:
+
+- implement the code now,
+- leave the concrete registered URI and `.env.production` value as the only
+ outstanding item, clearly marked,
+- or register both candidate URLs deliberately if that is preferred — but say so
+ explicitly rather than leaving it ambiguous.
+
+---
+
+## Task 2 — remove the confirm/selection machinery
+
+A trusted client that derives the room server-side has nothing to "select." All
+of the following goes:
+
+| Delete | File |
+|---|---|
+| `ROOM_HTML` and the `/api/oauth/room` route | `backend/src/routes/oauth-pages.ts` |
+| `authorizeOAuthRoom` endpoint | `backend/src/oauth-room-authorization.ts` |
+| `selectRoomForOAuth`, `selectedRoomForOAuth`, `consumeRoomSelection` | `backend/src/rooms.ts` |
+| `oauth_room_selection` table + the v5 migration | `backend/src/db.ts` |
+| `postLogin.shouldRedirect` | `backend/src/auth.ts` |
+| the tests covering the above | `backend/src/__tests__/rooms.test.ts` |
+
+**Keep:**
+
+- `currentOAuthAuthorization()` / `parseOAuthAuthorizationQuery()` — still how
+ the room id is derived.
+- `oauthRoomContext` — still useful for the consent page shown to *untrusted*
+ clients, which keep normal consent.
+- The `oauth_query` body parameter on the remaining endpoints, **and its
+ comment**. It looks unused; it is what makes the provider's `before`
+ middleware fire and verify the signature. Do not "clean it up."
+- Fresh OAuth `state` per launch. It prevents callback mixups and CSRF. It is
+ not what was causing consent to re-prompt.
+
+`consentReferenceId` keeps the ownership check — parse the room from the
+authorization query, `getRoomForUser(roomId, user.id)`, return the id or throw.
+That check is now load-bearing on its own; it must fail closed.
+
+Removing the v5 migration means the schema version drops. Decide deliberately
+whether to renumber (clean, but breaks any existing dev DB) or add a v6 that
+drops the table (safer). Document the choice — `db.test.ts` asserts the version
+number and will need updating either way.
+
+Note: this deletes the concurrent-tab state-keying from an earlier review round.
+That fix was correct for the design as it stood; it simply stops being needed
+once the page it protected is gone. Do not preserve it out of sunk cost.
+
+---
+
+## Task 3 — full no-screen integration test
+
+Current coverage has a real gap. `oauth-room-middleware.integration.test.ts`
+pins the signed-query middleware well, and `oauth-room-resource.test.ts` covers
+the resource boundary — but it **injects a mocked `verifyOAuthAccessToken`**.
+Nothing exercises a genuine signed token end to end.
+
+Add one integration test, in the style of the existing middleware test (real
+`auth.handler`, real adapter, temp `DATA_DIR`), covering:
+
+```
+trusted pre-registered client
+ → /oauth2/authorize with an existing session
+ → skipConsent (assert NO redirect to a consent or room page)
+ → server-side room ownership check
+ → authorization code at the exact registered redirect URI
+ → /oauth2/token with code + code_verifier
+ → REAL signed access token (not mocked)
+ → GET /api/rooms/:roomId with that token
+ → an authenticated OpenAI-proxy request with that token
+```
+
+Assert along the way:
+
+- the authorize response goes **straight to the redirect URI**, not to
+ `/api/oauth/room` or `/api/oauth/consent`
+- the issued token carries the expected `room_id` claim
+- `GET /api/rooms/` with that token returns **403**
+- a room owned by a *different* user fails the ownership check rather than
+ issuing a token
+
+Also add the negative case: an untrusted (dynamically registered, if any can
+still exist) or unregistered client is refused.
+
+---
+
+## Also in scope
+
+Stale comments in `frontend/src/pages/tools/index.tsx` still describe the device
+allowlist as a requirement for first-party tools:
+
+- `:10` — "such a tool signs in for itself via the device flow"
+- `:30` — "must also be listed in the backend device allowlist"
+- `:43` — `BETTER_AUTH_DEVICE_CLIENT_IDS`
+
+Under this flow Mindmap's launch never reaches `deviceClientIds()` — that gate
+is at `backend/src/app.ts:500`, on grant creation, and `POST /api/rooms`
+(`app.ts:296`) authenticates with the taskpane session alone. Correct the
+comments to say the device allowlist applies to **grant-based tools**, and note
+that the tool/device-client split is still owed for those.
+
+---
+
+## Out of scope
+
+Do not start these; they belong to a later polish PR:
+
+- room lifecycle (every launch creates a new room; no TTL, no dedup)
+- a user-facing room list or delete endpoint
+- refresh tokens / the 1-hour expiry UX
+- the raw-SQL user lookup in `resolveUser`'s OAuth branch
+- CSS extraction / inline-`