diff --git a/controlplane/src/auth/middleware.ts b/controlplane/src/auth/middleware.ts index c42dfa2b..1694884b 100644 --- a/controlplane/src/auth/middleware.ts +++ b/controlplane/src/auth/middleware.ts @@ -57,9 +57,13 @@ export async function authenticate( return authenticateWithCfAccеss(request, env); } - // Fall back to dev auth if enabled + // Fall back to dev auth if enabled. Dev-auth is header-spoofable, and on + // desktop a process inside the sandbox VM can reach the control plane — so when + // a per-boot SURFACE_TOKEN is provisioned, only honor dev-auth for requests + // from the trusted host frontend (matching X-Orcabot-Surface). No token + // (cloud, local dev, older builds) = unchanged behavior. const devAuthEnabled = env.DEV_AUTH_ENABLED === 'true'; - if (devAuthEnabled) { + if (devAuthEnabled && devAuthSurfaceTrusted(request, env)) { return authenticateDevMоde(request, env); } @@ -67,6 +71,23 @@ export async function authenticate( return { user: null, isAuthenticated: false }; } +/** + * Desktop trust boundary: dev-auth is header-spoofable and the sandbox VM can + * reach the control plane. When a per-boot SURFACE_TOKEN is provisioned, only + * the host frontend (which sends the matching X-Orcabot-Surface header) may use + * dev-auth. When unset (cloud / local dev / older builds), enforcement is off. + */ +function devAuthSurfaceTrusted(request: Request, env: Env): boolean { + const expected = env.SURFACE_TOKEN; + if (!expected) return true; + // Header for fetch/XHR; query param for top-level browser navigations (OAuth + // connect opened in the OS browser), which can't set custom headers. + const got = + request.headers.get('X-Orcabot-Surface') || + new URL(request.url).searchParams.get('surface'); + return got === expected; +} + // Production: Cloudflare Access JWT validation async function authenticateWithCfAccеss( request: Request, diff --git a/controlplane/src/index.ts b/controlplane/src/index.ts index 83fa5036..2a31ff99 100644 --- a/controlplane/src/index.ts +++ b/controlplane/src/index.ts @@ -61,7 +61,7 @@ export { ASRStreamProxy } from './asr/ASRStreamProxy'; // CORS headers (base - origin is added dynamically) const CORS_METHODS = 'GET, POST, PUT, PATCH, DELETE, OPTIONS'; -const CORS_ALLOWED_HEADERS = 'Content-Type, X-User-ID, X-User-Email, X-User-Name'; +const CORS_ALLOWED_HEADERS = 'Content-Type, X-User-ID, X-User-Email, X-User-Name, X-Orcabot-Surface'; /** * Parse allowed origins from env. Returns null if all origins allowed (dev mode). @@ -1267,6 +1267,11 @@ async function handleRequest(request: Request, env: EnvWithBindings, ctx: Pick integrations.callbackGithub(request, env), + 'POST github/device/start': integrations.deviceStartGithub, + 'POST github/device/poll': integrations.devicePollGithub, 'GET github': integrations.getGithubIntegratiоn, 'GET github/repos': integrations.getGithubRepоs, 'GET github/history': integrations.getGithubRepoHistory, diff --git a/controlplane/src/integration-policies/token-refresh.ts b/controlplane/src/integration-policies/token-refresh.ts index 43a7ea85..78cee9f3 100644 --- a/controlplane/src/integration-policies/token-refresh.ts +++ b/controlplane/src/integration-policies/token-refresh.ts @@ -218,17 +218,19 @@ async function refreshOAuthToken( } else if (provider === 'teams' || provider === 'outlook' || provider === 'onedrive' || provider === 'outlook_calendar') { const clientId = env.MICROSOFT_CLIENT_ID || env.ONEDRIVE_CLIENT_ID; const clientSecret = env.MICROSOFT_CLIENT_SECRET || env.ONEDRIVE_CLIENT_SECRET; - if (!clientId || !clientSecret) { + // Desktop public client: PKCE-issued tokens refresh with no secret. + const isPublicClient = env.OAUTH_PUBLIC_CLIENT === 'true'; + if (!clientId || (!isPublicClient && !clientSecret)) { console.error(`[token-refresh] Microsoft OAuth not configured for ${provider} refresh (userIntegrationId=${userIntegrationId})`); return null; } tokenUrl = 'https://login.microsoftonline.com/common/oauth2/v2.0/token'; body = new URLSearchParams({ client_id: clientId, - client_secret: clientSecret, grant_type: 'refresh_token', refresh_token: refreshToken, }); + if (!isPublicClient) body.set('client_secret', clientSecret!); } else { // Browser and other providers don't use OAuth refresh console.warn(`[token-refresh] OAuth refresh not supported for provider: ${provider}`); diff --git a/controlplane/src/integrations/handler.ts b/controlplane/src/integrations/handler.ts index cba7692a..c07e67f9 100644 --- a/controlplane/src/integrations/handler.ts +++ b/controlplane/src/integrations/handler.ts @@ -441,6 +441,47 @@ async function consumeState(env: EnvWithDriveCache, state: string, provider: str return record; } +// ---- Desktop public-client OAuth (PKCE) ----------------------------------- +// REVISION: oauth-pkce-v1 +// The desktop build is a *public* OAuth client: it ships an embedded client_id +// and can't protect a confidential secret. So on desktop we bind each authorize +// request with PKCE (RFC 7636) — the S256 challenge goes on the auth URL, the +// verifier rides along in the state metadata, and the callback replays it at +// token exchange. Cloud leaves OAUTH_PUBLIC_CLIENT unset, so these are no-ops +// and the existing confidential (client_secret) flow is unchanged. +function usePublicClient(env: EnvWithDriveCache): boolean { + return env.OAUTH_PUBLIC_CLIENT === 'true'; +} + +/** + * On the public-client path, generate a PKCE verifier, stash it in `metadata` + * (so the callback can read it back via consumeState), and return the S256 + * challenge to place on the auth URL. Returns null on the confidential path. + * Mutates `metadata` — call before createState so the verifier is persisted. + */ +async function pkceChallengeForState( + env: EnvWithDriveCache, + metadata: Record +): Promise { + if (!usePublicClient(env)) return null; + const verifier = generateCodeVerifier(); + metadata.code_verifier = verifier; + return await generateCodeChallenge(verifier); +} + +/** Replay the PKCE verifier (if any) from state metadata into a token-exchange body. */ +function applyPkceVerifier( + env: EnvWithDriveCache, + body: URLSearchParams, + metadata: Record | undefined +): void { + if (!usePublicClient(env)) return; + const verifier = metadata?.code_verifier; + if (typeof verifier === 'string' && verifier) { + body.set('code_verifier', verifier); + } +} + async function refreshGoogleAccessToken(env: EnvWithDriveCache, userId: string): Promise { if (!env.GOOGLE_CLIENT_ID || !env.GOOGLE_CLIENT_SECRET) { throw new Error('Google OAuth is not configured.'); @@ -595,7 +636,7 @@ async function refreshBoxAccessToken(env: EnvWithDriveCache, userId: string): Pr } async function refreshOnedriveAccessToken(env: EnvWithDriveCache, userId: string): Promise { - if (!env.ONEDRIVE_CLIENT_ID || !env.ONEDRIVE_CLIENT_SECRET) { + if (!env.ONEDRIVE_CLIENT_ID || (!usePublicClient(env) && !env.ONEDRIVE_CLIENT_SECRET)) { throw new Error('OneDrive OAuth is not configured.'); } @@ -610,7 +651,7 @@ async function refreshOnedriveAccessToken(env: EnvWithDriveCache, userId: string const body = new URLSearchParams(); body.set('client_id', env.ONEDRIVE_CLIENT_ID); - body.set('client_secret', env.ONEDRIVE_CLIENT_SECRET); + if (!usePublicClient(env)) body.set('client_secret', env.ONEDRIVE_CLIENT_SECRET!); body.set('grant_type', 'refresh_token'); body.set('refresh_token', record.refresh_token); body.set('scope', ONEDRIVE_SCOPE.join(' ')); @@ -805,8 +846,9 @@ function renderSuccessPage(providerLabel: string): Response {

${safeLabel} connected

-

You can close this tab and return to OrcaBot.

- +

You're all set — close this tab and return to OrcaBot.

+ +
`, @@ -1169,10 +1211,12 @@ export async function cоnnectGооgleDrive( const requestUrl = new URL(request.url); const dashboardId = requestUrl.searchParams.get('dashboard_id'); const mode = requestUrl.searchParams.get('mode'); - await createState(env, auth.user!.id, 'google_drive', state, { + const metadata: Record = { dashboard_id: dashboardId, popup: mode === 'popup', - }); + }; + const codeChallenge = await pkceChallengeForState(env, metadata); + await createState(env, auth.user!.id, 'google_drive', state, metadata); const redirectBase = getRedirectBase(request, env); const redirectUri = `${redirectBase}/integrations/google/drive/callback`; @@ -1186,6 +1230,10 @@ export async function cоnnectGооgleDrive( authUrl.searchParams.set('prompt', 'select_account'); authUrl.searchParams.set('include_granted_scopes', 'true'); authUrl.searchParams.set('state', state); + if (codeChallenge) { + authUrl.searchParams.set('code_challenge', codeChallenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + } return Response.redirect(authUrl.toString(), 302); } @@ -1223,6 +1271,7 @@ export async function callbackGооgleDrive( body.set('code', code); body.set('grant_type', 'authorization_code'); body.set('redirect_uri', redirectUri); + applyPkceVerifier(env, body, stateData.metadata); console.log(`Google Drive token exchange redirect_uri: ${redirectUri} dashboardId=${dashboardId}`); const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { @@ -4673,6 +4722,157 @@ export async function callbackGithub( return renderSuccessPage('GitHub'); } +// ---- GitHub device flow (desktop public client) --------------------------- +// REVISION: github-device-flow-v1 +// GitHub OAuth has no PKCE and its client secret is genuinely confidential, so a +// downloaded app can't use the redirect+secret flow. Instead we use the OAuth +// device flow (RFC 8628): no secret, no redirect — the app shows a user code, +// the user enters it at github.com/login/device, and we poll for the token. +// Gated to the public-client (desktop) build; cloud keeps the redirect flow. +function deviceJson(obj: unknown, status = 200): Response { + return new Response(JSON.stringify(obj), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +export async function deviceStartGithub( + request: Request, + env: EnvWithDriveCache, + auth: AuthContext +): Promise { + const authError = requireAuth(auth); + if (authError) return authError; + if (!usePublicClient(env)) { + return deviceJson({ error: 'Device flow is only available on the desktop build.' }, 400); + } + if (!env.GITHUB_CLIENT_ID) { + return deviceJson({ error: 'GitHub OAuth is not configured.' }, 400); + } + + let dashboardId: string | null = null; + try { + const b = await request.json() as { dashboard_id?: string }; + dashboardId = typeof b?.dashboard_id === 'string' ? b.dashboard_id : null; + } catch { /* body optional */ } + + const reqBody = new URLSearchParams(); + reqBody.set('client_id', env.GITHUB_CLIENT_ID); + reqBody.set('scope', GITHUB_SCOPE.join(' ')); + const resp = await fetch('https://github.com/login/device/code', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }, + body: reqBody, + }); + if (!resp.ok) { + return deviceJson({ error: 'Failed to start GitHub device flow.' }, 502); + } + const data = await resp.json() as { + device_code?: string; user_code?: string; verification_uri?: string; + expires_in?: number; interval?: number; error?: string; + }; + if (!data.device_code || !data.user_code || !data.verification_uri) { + return deviceJson({ error: data.error || 'GitHub device flow response was incomplete.' }, 502); + } + + // Stash the device_code under a random state handle; the frontend polls with it. + const state = buildState(); + await createState(env, auth.user!.id, 'github_device', state, { + device_code: data.device_code, + dashboardId, + }); + + return deviceJson({ + state, + user_code: data.user_code, + verification_uri: data.verification_uri, + expires_in: data.expires_in ?? 900, + interval: data.interval ?? 5, + }); +} + +export async function devicePollGithub( + request: Request, + env: EnvWithDriveCache, + auth: AuthContext +): Promise { + const authError = requireAuth(auth); + if (authError) return authError; + if (!env.GITHUB_CLIENT_ID) { + return deviceJson({ status: 'error', error: 'not_configured' }, 400); + } + + let state: string | null = null; + try { + const b = await request.json() as { state?: string }; + state = typeof b?.state === 'string' ? b.state : null; + } catch { /* handled below */ } + if (!state) { + return deviceJson({ status: 'error', error: 'missing_state' }, 400); + } + + // Look up (do NOT consume) the device_code so the frontend can poll repeatedly. + const stateData = await getState(env, state, 'github_device'); + if (!stateData) { + return deviceJson({ status: 'error', error: 'expired' }); + } + const deviceCode = stateData.metadata.device_code; + if (typeof deviceCode !== 'string') { + return deviceJson({ status: 'error', error: 'invalid' }); + } + + const reqBody = new URLSearchParams(); + reqBody.set('client_id', env.GITHUB_CLIENT_ID); + reqBody.set('device_code', deviceCode); + reqBody.set('grant_type', 'urn:ietf:params:oauth:grant-type:device_code'); + const resp = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Accept': 'application/json' }, + body: reqBody, + }); + const data = await resp.json() as { + access_token?: string; refresh_token?: string; scope?: string; + token_type?: string; expires_in?: number; error?: string; + }; + + // Still waiting on the user, or told to back off. + if (data.error === 'authorization_pending') return deviceJson({ status: 'pending' }); + if (data.error === 'slow_down') return deviceJson({ status: 'slow_down' }); + if (data.error === 'expired_token') { await deleteState(env, state); return deviceJson({ status: 'error', error: 'expired' }); } + if (data.error === 'access_denied') { await deleteState(env, state); return deviceJson({ status: 'error', error: 'denied' }); } + if (data.error) return deviceJson({ status: 'error', error: data.error }); + if (!data.access_token) return deviceJson({ status: 'pending' }); + + // Success — persist the token exactly like the redirect callback does. + const metadata = JSON.stringify({ scope: data.scope, token_type: data.token_type }); + const expiresAt = data.expires_in ? new Date(Date.now() + data.expires_in * 1000).toISOString() : null; + await env.DB.prepare(` + INSERT INTO user_integrations ( + id, user_id, provider, access_token, refresh_token, scope, token_type, expires_at, metadata + ) VALUES (?, ?, 'github', ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, provider) DO UPDATE SET + access_token = excluded.access_token, + refresh_token = COALESCE(excluded.refresh_token, user_integrations.refresh_token), + scope = excluded.scope, + token_type = excluded.token_type, + expires_at = excluded.expires_at, + metadata = excluded.metadata, + updated_at = datetime('now') + `).bind( + crypto.randomUUID(), + stateData.userId, + data.access_token, + data.refresh_token || null, + data.scope || null, + data.token_type || null, + expiresAt, + metadata + ).run(); + await deleteState(env, state); + + return deviceJson({ status: 'complete' }); +} + export async function cоnnectBоx( request: Request, env: EnvWithDriveCache, @@ -4804,7 +5004,7 @@ export async function cоnnectОnedrive( const authError = requireAuth(auth); if (authError) return authError; - if (!env.ONEDRIVE_CLIENT_ID || !env.ONEDRIVE_CLIENT_SECRET) { + if (!env.ONEDRIVE_CLIENT_ID || (!usePublicClient(env) && !env.ONEDRIVE_CLIENT_SECRET)) { return renderErrorPage('OneDrive OAuth is not configured.'); } @@ -4812,10 +5012,12 @@ export async function cоnnectОnedrive( const mode = url.searchParams.get('mode'); const dashboardId = url.searchParams.get('dashboard_id'); const state = buildState(); - await createState(env, auth.user!.id, 'onedrive', state, { + const metadata: Record = { mode, dashboardId, - }); + }; + const codeChallenge = await pkceChallengeForState(env, metadata); + await createState(env, auth.user!.id, 'onedrive', state, metadata); const redirectBase = getRedirectBase(request, env); const redirectUri = `${redirectBase}/integrations/onedrive/callback`; @@ -4828,6 +5030,10 @@ export async function cоnnectОnedrive( authUrl.searchParams.set('scope', ONEDRIVE_SCOPE.join(' ')); authUrl.searchParams.set('state', state); authUrl.searchParams.set('prompt', 'select_account'); + if (codeChallenge) { + authUrl.searchParams.set('code_challenge', codeChallenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + } return Response.redirect(authUrl.toString(), 302); } @@ -4836,7 +5042,7 @@ export async function callbackОnedrive( request: Request, env: EnvWithDriveCache ): Promise { - if (!env.ONEDRIVE_CLIENT_ID || !env.ONEDRIVE_CLIENT_SECRET) { + if (!env.ONEDRIVE_CLIENT_ID || (!usePublicClient(env) && !env.ONEDRIVE_CLIENT_SECRET)) { return renderErrorPage('OneDrive OAuth is not configured.'); } @@ -4857,11 +5063,12 @@ export async function callbackОnedrive( const body = new URLSearchParams(); body.set('client_id', env.ONEDRIVE_CLIENT_ID); - body.set('client_secret', env.ONEDRIVE_CLIENT_SECRET); + if (!usePublicClient(env)) body.set('client_secret', env.ONEDRIVE_CLIENT_SECRET!); body.set('grant_type', 'authorization_code'); body.set('code', code); body.set('redirect_uri', redirectUri); body.set('scope', ONEDRIVE_SCOPE.join(' ')); + applyPkceVerifier(env, body, stateData.metadata); console.log(`OneDrive token exchange redirect_uri: ${redirectUri} dashboardId=${stateData.metadata.dashboardId}`); const tokenResponse = await fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', { @@ -5226,10 +5433,12 @@ export async function connectGmail( const requestUrl = new URL(request.url); const dashboardId = requestUrl.searchParams.get('dashboard_id'); const mode = requestUrl.searchParams.get('mode'); - await createState(env, auth.user!.id, 'gmail', state, { + const metadata: Record = { dashboard_id: dashboardId, popup: mode === 'popup', - }); + }; + const codeChallenge = await pkceChallengeForState(env, metadata); + await createState(env, auth.user!.id, 'gmail', state, metadata); const redirectBase = getRedirectBase(request, env); const redirectUri = `${redirectBase}/integrations/google/gmail/callback`; @@ -5243,6 +5452,10 @@ export async function connectGmail( authUrl.searchParams.set('prompt', 'select_account'); authUrl.searchParams.set('include_granted_scopes', 'true'); authUrl.searchParams.set('state', state); + if (codeChallenge) { + authUrl.searchParams.set('code_challenge', codeChallenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + } return Response.redirect(authUrl.toString(), 302); } @@ -5280,6 +5493,7 @@ export async function callbackGmail( body.set('code', code); body.set('grant_type', 'authorization_code'); body.set('redirect_uri', redirectUri); + applyPkceVerifier(env, body, stateData.metadata); const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', @@ -6439,10 +6653,12 @@ export async function connectCalendar( const requestUrl = new URL(request.url); const dashboardId = requestUrl.searchParams.get('dashboard_id'); const mode = requestUrl.searchParams.get('mode'); - await createState(env, auth.user!.id, 'google_calendar', state, { + const metadata: Record = { dashboard_id: dashboardId, popup: mode === 'popup', - }); + }; + const codeChallenge = await pkceChallengeForState(env, metadata); + await createState(env, auth.user!.id, 'google_calendar', state, metadata); const redirectBase = getRedirectBase(request, env); const redirectUri = `${redirectBase}/integrations/google/calendar/callback`; @@ -6456,6 +6672,10 @@ export async function connectCalendar( authUrl.searchParams.set('prompt', 'select_account'); authUrl.searchParams.set('include_granted_scopes', 'true'); authUrl.searchParams.set('state', state); + if (codeChallenge) { + authUrl.searchParams.set('code_challenge', codeChallenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + } return Response.redirect(authUrl.toString(), 302); } @@ -6493,6 +6713,7 @@ export async function callbackCalendar( body.set('code', code); body.set('grant_type', 'authorization_code'); body.set('redirect_uri', redirectUri); + applyPkceVerifier(env, body, stateData.metadata); const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', @@ -7382,10 +7603,12 @@ export async function connectContacts( const requestUrl = new URL(request.url); const dashboardId = requestUrl.searchParams.get('dashboard_id'); const mode = requestUrl.searchParams.get('mode'); - await createState(env, auth.user!.id, 'google_contacts', state, { + const metadata: Record = { dashboard_id: dashboardId, popup: mode === 'popup', - }); + }; + const codeChallenge = await pkceChallengeForState(env, metadata); + await createState(env, auth.user!.id, 'google_contacts', state, metadata); const redirectBase = getRedirectBase(request, env); const redirectUri = `${redirectBase}/integrations/google/contacts/callback`; @@ -7399,6 +7622,10 @@ export async function connectContacts( authUrl.searchParams.set('prompt', 'select_account'); authUrl.searchParams.set('include_granted_scopes', 'true'); authUrl.searchParams.set('state', state); + if (codeChallenge) { + authUrl.searchParams.set('code_challenge', codeChallenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + } return Response.redirect(authUrl.toString(), 302); } @@ -7436,6 +7663,7 @@ export async function callbackContacts( body.set('code', code); body.set('grant_type', 'authorization_code'); body.set('redirect_uri', redirectUri); + applyPkceVerifier(env, body, stateData.metadata); const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', @@ -8345,10 +8573,12 @@ export async function connectSheets( const requestUrl = new URL(request.url); const dashboardId = requestUrl.searchParams.get('dashboard_id'); const mode = requestUrl.searchParams.get('mode'); - await createState(env, auth.user!.id, 'google_sheets', state, { + const metadata: Record = { dashboard_id: dashboardId, popup: mode === 'popup', - }); + }; + const codeChallenge = await pkceChallengeForState(env, metadata); + await createState(env, auth.user!.id, 'google_sheets', state, metadata); const redirectBase = getRedirectBase(request, env); const redirectUri = `${redirectBase}/integrations/google/sheets/callback`; @@ -8362,6 +8592,10 @@ export async function connectSheets( authUrl.searchParams.set('prompt', 'select_account'); authUrl.searchParams.set('include_granted_scopes', 'true'); authUrl.searchParams.set('state', state); + if (codeChallenge) { + authUrl.searchParams.set('code_challenge', codeChallenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + } return Response.redirect(authUrl.toString(), 302); } @@ -8399,6 +8633,7 @@ export async function callbackSheets( body.set('code', code); body.set('grant_type', 'authorization_code'); body.set('redirect_uri', redirectUri); + applyPkceVerifier(env, body, stateData.metadata); const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', @@ -9047,10 +9282,12 @@ export async function connectForms( const requestUrl = new URL(request.url); const dashboardId = requestUrl.searchParams.get('dashboard_id'); const mode = requestUrl.searchParams.get('mode'); - await createState(env, auth.user!.id, 'google_forms', state, { + const metadata: Record = { dashboard_id: dashboardId, popup: mode === 'popup', - }); + }; + const codeChallenge = await pkceChallengeForState(env, metadata); + await createState(env, auth.user!.id, 'google_forms', state, metadata); const redirectBase = getRedirectBase(request, env); const redirectUri = `${redirectBase}/integrations/google/forms/callback`; @@ -9064,6 +9301,10 @@ export async function connectForms( authUrl.searchParams.set('prompt', 'select_account'); authUrl.searchParams.set('include_granted_scopes', 'true'); authUrl.searchParams.set('state', state); + if (codeChallenge) { + authUrl.searchParams.set('code_challenge', codeChallenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + } return Response.redirect(authUrl.toString(), 302); } @@ -9101,6 +9342,7 @@ export async function callbackForms( body.set('code', code); body.set('grant_type', 'authorization_code'); body.set('redirect_uri', redirectUri); + applyPkceVerifier(env, body, stateData.metadata); const tokenResponse = await fetch('https://oauth2.googleapis.com/token', { method: 'POST', @@ -11351,7 +11593,7 @@ export async function connectTeams( const clientId = env.MICROSOFT_CLIENT_ID || env.ONEDRIVE_CLIENT_ID; const clientSecret = env.MICROSOFT_CLIENT_SECRET || env.ONEDRIVE_CLIENT_SECRET; - if (!clientId || !clientSecret) { + if (!clientId || (!usePublicClient(env) && !clientSecret)) { return renderErrorPage('Microsoft OAuth is not configured.'); } @@ -11359,10 +11601,12 @@ export async function connectTeams( const mode = url.searchParams.get('mode'); const dashboardId = url.searchParams.get('dashboard_id'); const state = buildState(); - await createState(env, auth.user!.id, 'teams', state, { + const metadata: Record = { mode, dashboardId, - }); + }; + const codeChallenge = await pkceChallengeForState(env, metadata); + await createState(env, auth.user!.id, 'teams', state, metadata); const redirectBase = getRedirectBase(request, env); const redirectUri = `${redirectBase}/integrations/teams/callback`; @@ -11375,6 +11619,10 @@ export async function connectTeams( authUrl.searchParams.set('scope', TEAMS_OAUTH_SCOPE.join(' ')); authUrl.searchParams.set('state', state); authUrl.searchParams.set('prompt', 'select_account'); + if (codeChallenge) { + authUrl.searchParams.set('code_challenge', codeChallenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + } return Response.redirect(authUrl.toString(), 302); } @@ -11385,7 +11633,7 @@ export async function callbackTeams( ): Promise { const clientId = env.MICROSOFT_CLIENT_ID || env.ONEDRIVE_CLIENT_ID; const clientSecret = env.MICROSOFT_CLIENT_SECRET || env.ONEDRIVE_CLIENT_SECRET; - if (!clientId || !clientSecret) { + if (!clientId || (!usePublicClient(env) && !clientSecret)) { return renderErrorPage('Microsoft OAuth is not configured.'); } @@ -11411,11 +11659,12 @@ export async function callbackTeams( const body = new URLSearchParams(); body.set('client_id', clientId); - body.set('client_secret', clientSecret); + if (!usePublicClient(env)) body.set('client_secret', clientSecret!); body.set('grant_type', 'authorization_code'); body.set('code', code); body.set('redirect_uri', redirectUri); body.set('scope', TEAMS_OAUTH_SCOPE.join(' ')); + applyPkceVerifier(env, body, stateData.metadata); console.log(`Teams token exchange redirect_uri: ${redirectUri} dashboardId=${stateData.metadata.dashboardId}`); const tokenResponse = await fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', { @@ -11536,7 +11785,7 @@ export async function connectOutlook( const clientId = env.MICROSOFT_CLIENT_ID || env.ONEDRIVE_CLIENT_ID; const clientSecret = env.MICROSOFT_CLIENT_SECRET || env.ONEDRIVE_CLIENT_SECRET; - if (!clientId || !clientSecret) { + if (!clientId || (!usePublicClient(env) && !clientSecret)) { return renderErrorPage('Microsoft OAuth is not configured.'); } @@ -11544,10 +11793,12 @@ export async function connectOutlook( const mode = url.searchParams.get('mode'); const dashboardId = url.searchParams.get('dashboard_id'); const state = buildState(); - await createState(env, auth.user!.id, 'outlook', state, { + const metadata: Record = { mode, dashboardId, - }); + }; + const codeChallenge = await pkceChallengeForState(env, metadata); + await createState(env, auth.user!.id, 'outlook', state, metadata); const redirectBase = getRedirectBase(request, env); const redirectUri = `${redirectBase}/integrations/outlook/callback`; @@ -11560,6 +11811,10 @@ export async function connectOutlook( authUrl.searchParams.set('scope', OUTLOOK_SCOPE.join(' ')); authUrl.searchParams.set('state', state); authUrl.searchParams.set('prompt', 'select_account'); + if (codeChallenge) { + authUrl.searchParams.set('code_challenge', codeChallenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + } return Response.redirect(authUrl.toString(), 302); } @@ -11570,7 +11825,7 @@ export async function callbackOutlook( ): Promise { const clientId = env.MICROSOFT_CLIENT_ID || env.ONEDRIVE_CLIENT_ID; const clientSecret = env.MICROSOFT_CLIENT_SECRET || env.ONEDRIVE_CLIENT_SECRET; - if (!clientId || !clientSecret) { + if (!clientId || (!usePublicClient(env) && !clientSecret)) { return renderErrorPage('Microsoft OAuth is not configured.'); } @@ -11592,11 +11847,12 @@ export async function callbackOutlook( const body = new URLSearchParams(); body.set('client_id', clientId); - body.set('client_secret', clientSecret); + if (!usePublicClient(env)) body.set('client_secret', clientSecret!); body.set('grant_type', 'authorization_code'); body.set('code', code); body.set('redirect_uri', redirectUri); body.set('scope', OUTLOOK_SCOPE.join(' ')); + applyPkceVerifier(env, body, stateData.metadata); console.log(`Outlook token exchange redirect_uri: ${redirectUri} dashboardId=${stateData.metadata.dashboardId}`); const tokenResponse = await fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', { @@ -11709,7 +11965,7 @@ export async function getOutlookIntegration( async function refreshOutlookAccessToken(env: EnvWithDriveCache, userId: string): Promise { const clientId = env.MICROSOFT_CLIENT_ID || env.ONEDRIVE_CLIENT_ID; const clientSecret = env.MICROSOFT_CLIENT_SECRET || env.ONEDRIVE_CLIENT_SECRET; - if (!clientId || !clientSecret) { + if (!clientId || (!usePublicClient(env) && !clientSecret)) { throw new Error('Microsoft OAuth is not configured.'); } @@ -11736,7 +11992,7 @@ async function refreshOutlookAccessToken(env: EnvWithDriveCache, userId: string) const body = new URLSearchParams(); body.set('client_id', clientId); - body.set('client_secret', clientSecret); + if (!usePublicClient(env)) body.set('client_secret', clientSecret!); body.set('grant_type', 'refresh_token'); body.set('refresh_token', record.refresh_token); body.set('scope', OUTLOOK_SCOPE.join(' ')); @@ -12281,7 +12537,7 @@ export async function connectOutlookCalendar( const clientId = env.MICROSOFT_CLIENT_ID || env.ONEDRIVE_CLIENT_ID; const clientSecret = env.MICROSOFT_CLIENT_SECRET || env.ONEDRIVE_CLIENT_SECRET; - if (!clientId || !clientSecret) { + if (!clientId || (!usePublicClient(env) && !clientSecret)) { return renderErrorPage('Microsoft OAuth is not configured.'); } @@ -12289,10 +12545,12 @@ export async function connectOutlookCalendar( const mode = url.searchParams.get('mode'); const dashboardId = url.searchParams.get('dashboard_id'); const state = buildState(); - await createState(env, auth.user!.id, 'outlook_calendar', state, { + const metadata: Record = { mode, dashboardId, - }); + }; + const codeChallenge = await pkceChallengeForState(env, metadata); + await createState(env, auth.user!.id, 'outlook_calendar', state, metadata); const redirectBase = getRedirectBase(request, env); const redirectUri = `${redirectBase}/integrations/outlook/calendar/callback`; @@ -12305,6 +12563,10 @@ export async function connectOutlookCalendar( authUrl.searchParams.set('scope', OUTLOOK_CALENDAR_SCOPE.join(' ')); authUrl.searchParams.set('state', state); authUrl.searchParams.set('prompt', 'select_account'); + if (codeChallenge) { + authUrl.searchParams.set('code_challenge', codeChallenge); + authUrl.searchParams.set('code_challenge_method', 'S256'); + } return Response.redirect(authUrl.toString(), 302); } @@ -12315,7 +12577,7 @@ export async function callbackOutlookCalendar( ): Promise { const clientId = env.MICROSOFT_CLIENT_ID || env.ONEDRIVE_CLIENT_ID; const clientSecret = env.MICROSOFT_CLIENT_SECRET || env.ONEDRIVE_CLIENT_SECRET; - if (!clientId || !clientSecret) { + if (!clientId || (!usePublicClient(env) && !clientSecret)) { return renderErrorPage('Microsoft OAuth is not configured.'); } @@ -12341,11 +12603,12 @@ export async function callbackOutlookCalendar( const body = new URLSearchParams(); body.set('client_id', clientId); - body.set('client_secret', clientSecret); + if (!usePublicClient(env)) body.set('client_secret', clientSecret!); body.set('grant_type', 'authorization_code'); body.set('code', code); body.set('redirect_uri', redirectUri); body.set('scope', OUTLOOK_CALENDAR_SCOPE.join(' ')); + applyPkceVerifier(env, body, stateData.metadata); console.log(`Outlook Calendar token exchange redirect_uri: ${redirectUri} dashboardId=${stateData.metadata.dashboardId}`); const tokenResponse = await fetch('https://login.microsoftonline.com/common/oauth2/v2.0/token', { diff --git a/controlplane/src/types.ts b/controlplane/src/types.ts index 4107ed31..7779214c 100644 --- a/controlplane/src/types.ts +++ b/controlplane/src/types.ts @@ -31,6 +31,9 @@ export interface Env { INTERNAL_API_TOKEN: string; SANDBOX_INTERNAL_TOKEN: string; DEV_AUTH_ENABLED?: string; + /** Desktop per-boot token: when set, dev-auth is only honored for requests + * carrying a matching X-Orcabot-Surface header (blocks VM-origin spoofing). */ + SURFACE_TOKEN?: string; GOOGLE_CLIENT_ID?: string; GOOGLE_CLIENT_SECRET?: string; AUTH_ALLOWED_EMAILS?: string; @@ -60,6 +63,8 @@ export interface Env { /** WhatsApp Business phone number in E.164 digits-only (e.g., "15551234567") for hybrid mode */ WHATSAPP_BUSINESS_PHONE?: string; OAUTH_REDIRECT_BASE?: string; + /** "true" on the desktop build → OAuth uses the public-client (PKCE) flow. */ + OAUTH_PUBLIC_CLIENT?: string; FRONTEND_URL?: string; /** Comma-separated list of allowed CORS origins. If not set, allows all origins (dev mode). */ ALLOWED_ORIGINS?: string; diff --git a/desktop/CLAUDE.md b/desktop/CLAUDE.md index 04ed96ba..02ddab01 100644 --- a/desktop/CLAUDE.md +++ b/desktop/CLAUDE.md @@ -214,6 +214,30 @@ NOT transferred. See `controlplane/CLAUDE.md` (file proxies, PATs). ## Security +### Desktop trust boundary (dev-auth + surface token) +On desktop, auth is **dev-auth** (`DEV_AUTH_ENABLED=true`): the control plane +trusts `X-User-ID`/`X-User-Email` headers. Those headers are **spoofable**, and a +process inside the sandbox VM **can reach the control plane** at `127.0.0.1:8787` +(the guest→host reverse bridge; localhost bypasses the egress proxy). So dev-auth +alone must **not** be enough to act as the user from inside the VM. + +Guard: the desktop app generates a **per-boot `SURFACE_TOKEN`** (`surface_token()` +in `main.rs`) and (a) passes it to the control-plane worker and (b) hands it to the +GUI webview via the `get_surface_token` Tauri command. The frontend sends it as the +`X-Orcabot-Surface` header on control-plane calls (fetched+cached in +`frontend/src/lib/tauri-bridge.ts`, ensured before the first authed request in +`api/client.ts`), and the control plane **only honors dev-auth when the header +matches** (`devAuthSurfaceTrusted` in `controlplane/src/auth/middleware.ts`). The +VM never gets the token (filtered from PTY env) and can't reach the frontend on +`:8788` to scrape it (only `:8080` and `:8787` are bridged), so a VM process is +confined to the token-authed paths it's supposed to use: the integration gateway +(PTY token) and `/internal/*` (`INTERNAL_API_TOKEN`). When `SURFACE_TOKEN` is unset +(cloud, local `wrangler dev`, older builds) enforcement is off and behavior is +unchanged. + +**Desktop-only**: production runs `DEV_AUTH_ENABLED=false` (real Cloudflare +Access), where header spoofing doesn't work in the first place. + ### Folder Import The `import_folder` Tauri command has hardened path handling: - Validates subpath has no `..` or absolute components diff --git a/desktop/app/src-tauri/src/bin/orcabot.rs b/desktop/app/src-tauri/src/bin/orcabot.rs index 7a2f168b..9af3d912 100644 --- a/desktop/app/src-tauri/src/bin/orcabot.rs +++ b/desktop/app/src-tauri/src/bin/orcabot.rs @@ -593,6 +593,16 @@ struct Remote { auth: RemoteAuth, } +/// Read the per-boot surface token written by orcabot-desktop to a host-only +/// file. The local control plane gates dev-auth behind it; the CLI is a trusted +/// host client, so it reads the file and sends the matching header. +fn read_surface_token() -> Option { + std::fs::read_to_string(data_dir().join("surface-token")) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + impl Remote { fn local() -> Remote { Remote { @@ -606,10 +616,20 @@ impl Remote { /// Apply the auth headers for this remote to a ureq request builder. fn apply_auth(&self, req: ureq::Request) -> ureq::Request { match &self.auth { - RemoteAuth::Dev { user } => req - .set("X-User-ID", user) - .set("X-User-Email", DEV_EMAIL) - .set("X-User-Name", DEV_NAME), + RemoteAuth::Dev { user } => { + let req = req + .set("X-User-ID", user) + .set("X-User-Email", DEV_EMAIL) + .set("X-User-Name", DEV_NAME); + // The local desktop control plane may gate dev-auth behind the + // per-boot surface token; include it for local targets so the CLI + // (a trusted host client) isn't rejected. Remote targets don't + // have this token and don't enforce it. + match (self.is_local(), read_surface_token()) { + (true, Some(t)) => req.set("X-Orcabot-Surface", &t), + _ => req, + } + } RemoteAuth::Bearer { token } => req.set("Authorization", &format!("Bearer {token}")), } } diff --git a/desktop/app/src-tauri/src/commands.rs b/desktop/app/src-tauri/src/commands.rs index 5551900d..fef3d2d4 100644 --- a/desktop/app/src-tauri/src/commands.rs +++ b/desktop/app/src-tauri/src/commands.rs @@ -608,6 +608,37 @@ pub fn quit_app(app: tauri::AppHandle) { app.exit(0); } +/// Return the per-boot surface token. The host frontend sends it as the +/// `X-Orcabot-Surface` header so the control plane knows the request is from the +/// trusted GUI (not a process inside the sandbox VM spoofing dev-auth). +#[tauri::command] +pub fn get_surface_token() -> String { + crate::surface_token().to_string() +} + +/// Open an http(s) URL in the OS default browser. OAuth connect flows use this +/// on desktop because `window.open` is a no-op inside the Tauri webview. +#[tauri::command] +pub fn open_url(url: String) -> Result<(), String> { + if !(url.starts_with("http://") || url.starts_with("https://")) { + return Err("only http(s) URLs are allowed".into()); + } + #[cfg(target_os = "macos")] + let mut cmd = std::process::Command::new("open"); + #[cfg(target_os = "linux")] + let mut cmd = std::process::Command::new("xdg-open"); + #[cfg(target_os = "windows")] + let mut cmd = { + let mut c = std::process::Command::new("cmd"); + c.args(["/C", "start", ""]); + c + }; + cmd.arg(&url) + .spawn() + .map(|_| ()) + .map_err(|e| format!("failed to open URL: {e}")) +} + #[tauri::command] pub fn switch_to_cli(app: tauri::AppHandle) -> Result<(), String> { #[cfg(target_os = "macos")] diff --git a/desktop/app/src-tauri/src/main.rs b/desktop/app/src-tauri/src/main.rs index 6a6208dd..e6e0d405 100644 --- a/desktop/app/src-tauri/src/main.rs +++ b/desktop/app/src-tauri/src/main.rs @@ -120,6 +120,43 @@ fn passthrough_env(workerd_env: &mut Vec<(&'static str, String)>, key: &'static } } +/// Per-boot token that gates dev-auth to the trusted host frontend. Generated +/// once at startup, passed to the control-plane worker (`SURFACE_TOKEN`) and +/// handed to the GUI webview via the `get_surface_token` command. The sandbox VM +/// can reach the control plane on :8787 but is never given this token — and it +/// can't reach the frontend on :8788 to scrape it — so a process in the VM can't +/// spoof dev-auth to impersonate the user. See desktop/CLAUDE.md (trust boundary). +static SURFACE_TOKEN: std::sync::OnceLock = std::sync::OnceLock::new(); + +pub fn surface_token() -> &'static str { + SURFACE_TOKEN.get_or_init(|| { + let mut buf = [0u8; 32]; + #[cfg(unix)] + { + use std::io::Read; + if let Ok(mut f) = std::fs::File::open("/dev/urandom") { + let _ = f.read_exact(&mut buf); + } + } + buf.iter().map(|b| format!("{:02x}", b)).collect() + }) +} + +/// Persist the surface token to a host-only file (0600) so trusted host clients +/// that use dev-auth — the `orcabot` CLI and scripts — can read it and send the +/// X-Orcabot-Surface header. The app-data dir is NOT shared into the sandbox VM +/// (only /workspace is), so a process in the VM can't read it. +fn write_surface_token_file(data_dir: &std::path::Path) { + let path = data_dir.join("surface-token"); + if std::fs::write(&path, surface_token()).is_ok() { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); + } + } +} + /// Kill any processes listed in a stale PID file from a previous run. fn cleanup_stale_processes(data_dir: &Path) { let pid_path = pid_file_path(data_dir); @@ -355,12 +392,18 @@ impl DesktopServices { } }; + // Host-only token file for trusted dev-auth clients (the CLI / scripts). + write_surface_token_file(&data_dir); + let mut workerd_env = vec![ ("D1_HTTP_URL", "http://d1-shim".to_string()), ("SANDBOX_URL", sandbox_url), ("SANDBOX_INTERNAL_TOKEN", sandbox_internal_token), ("INTERNAL_API_TOKEN", internal_api_token.clone()), ("DEV_AUTH_ENABLED", dev_auth_enabled), + // Gates dev-auth to the host frontend (which sends X-Orcabot-Surface with + // this value); the sandbox VM never gets it, so it can't spoof user auth. + ("SURFACE_TOKEN", surface_token().to_string()), ("SECRETS_ENCRYPTION_KEY", secrets_key), ("OAUTH_REDIRECT_BASE", std::env::var("OAUTH_REDIRECT_BASE") .unwrap_or_else(|_| format!("http://localhost:{}", controlplane_port))), @@ -768,6 +811,8 @@ fn main() { commands::import_folder, commands::switch_to_cli, commands::quit_app, + commands::get_surface_token, + commands::open_url, ]) .setup(|app| { let services = Arc::new(DesktopServices::new()); diff --git a/desktop/scripts/build-desktop-resources.sh b/desktop/scripts/build-desktop-resources.sh index c0235158..b023f3f1 100755 --- a/desktop/scripts/build-desktop-resources.sh +++ b/desktop/scripts/build-desktop-resources.sh @@ -38,6 +38,44 @@ fi cp "$ROOT_DIR/workerd/dist/worker.js" "$WORKERD_DIST_DIR/worker.js" cp "$ROOT_DIR/workerd/config/workerd.desktop.capnp" "$WORKERD_CONFIG_DIR/workerd.desktop.capnp" +# --- Bake OAuth client credentials into the bundled capnp (build-time) -------- +# A downloaded app can't read host env vars, so provider client IDs/secrets must +# ship *inside* the app. When set at build time, rewrite the matching capnp +# `fromEnvironment` bindings to literal `text` values in the STAGED copy only — +# source stays clean, so nothing lands in the (public) repo (which also keeps +# Google from auto-revoking an exposed secret). Client IDs are public; the +# desktop client secret is non-confidential per Google's installed-app model. +# Usage: GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=... cargo tauri build +STAGED_CAPNP="$WORKERD_CONFIG_DIR/workerd.desktop.capnp" +# Only NON-CONFIDENTIAL values may be baked into a shipped (inspectable) app: +# - public client IDs (Google/GitHub/Microsoft/OneDrive) +# - GOOGLE_CLIENT_SECRET — a Google "Desktop app" client secret, which Google +# explicitly treats as non-confidential and requires you to embed +# - GOOGLE_API_KEY — a browser/public API key +# GitHub uses device flow and Microsoft/OneDrive use PKCE, so no secrets there. +# Confidential-secret providers (Slack/Discord/X/Box) are NOT baked and stay +# hidden/disabled on desktop; a *_CLIENT_SECRET set for them is ignored here. +for _oauth_var in \ + GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET GOOGLE_API_KEY \ + GITHUB_CLIENT_ID \ + MICROSOFT_CLIENT_ID \ + ONEDRIVE_CLIENT_ID; do + _oauth_val=$(printenv "$_oauth_var" 2>/dev/null) || _oauth_val="" + [ -n "$_oauth_val" ] || continue + if python3 - "$STAGED_CAPNP" "$_oauth_var" "$_oauth_val" <<'PY' +import json, re, sys +cfg, name, value = sys.argv[1], sys.argv[2], sys.argv[3] +s = open(cfg).read() +pat = re.compile(r'\(name = "%s", fromEnvironment = "%s"\)' % (re.escape(name), re.escape(name))) +new, n = pat.subn('(name = "%s", text = %s)' % (name, json.dumps(value)), s) +if n: + open(cfg, 'w').write(new) +sys.exit(0 if n else 3) +PY + then printf '%s\n' " baked OAuth binding: $_oauth_var" + else printf '%s\n' " WARN: $_oauth_var set but no fromEnvironment binding found to bake"; fi +done + WORKERD_BIN="$CONTROLPLANE_DIR/node_modules/workerd/bin/workerd" if [ ! -x "$WORKERD_BIN" ]; then printf '%s\n' "workerd binary not found: $WORKERD_BIN" >&2 @@ -305,3 +343,19 @@ if [ -n "${APPLE_SIGNING_IDENTITY:-}" ]; then else printf '%s\n' "APPLE_SIGNING_IDENTITY not set — skipping nested-binary signing (dev build)" fi + +# Dev convenience: the `cargo build` output binary loads resources from +# target/release/resources (Tauri copies them there at build time), NOT from the +# source app/src-tauri/resources this script writes. Keep them in sync so a +# standalone resources rebuild takes effect on the next `orcabot up` without a +# full `cargo build`. Only touches the target dir if it already exists (a prior +# cargo build); the packaged `cargo tauri build` path copies resources itself. +TARGET_RES_DIR="$ROOT_DIR/app/src-tauri/target/release/resources" +if [ -d "$TARGET_RES_DIR" ]; then + if command -v rsync >/dev/null 2>&1; then + rsync -a "$TAURI_RESOURCES_DIR/" "$TARGET_RES_DIR/" + else + cp -R "$TAURI_RESOURCES_DIR/." "$TARGET_RES_DIR/" + fi + printf '%s\n' " synced resources -> target/release/resources (dev binary picks these up)" +fi diff --git a/desktop/tests/security-smoke.sh b/desktop/tests/security-smoke.sh new file mode 100755 index 00000000..9ba5836f --- /dev/null +++ b/desktop/tests/security-smoke.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Orcabot desktop SECURITY smoke — the dev-auth surface-token gate. +# +# Proves that on desktop, dev-auth (spoofable X-User-ID headers) is only honored +# for requests carrying the per-boot surface token — so a process inside the +# sandbox VM (which can't read the token) can't impersonate the user against +# user-scoped control-plane endpoints. +# +# Asserts against a user-scoped endpoint (GET /dashboards): +# 1. dev headers + VALID surface token -> 200 (trusted host client works) +# 2. dev headers + NO surface token -> 401 (simulates the VM agent -> blocked) +# 3. dev headers + WRONG surface token -> 401 +# +# SKIPs cleanly if the running stack predates the fix (no surface-token file, or +# enforcement not active). Requires the stack up (`orcabot up`) + curl. +# +# Usage: desktop/tests/security-smoke.sh +set -uo pipefail + +CP="http://127.0.0.1:8787" +ENDPOINT="/dashboards" # user-scoped; requires auth +TOKEN_FILE="${ORCABOT_SURFACE_TOKEN_FILE:-$HOME/Library/Application Support/com.orcabot.desktop/surface-token}" +DEV=(-H "X-User-ID: dev-desktop" -H "X-User-Email: desktop@localhost" -H "X-User-Name: Desktop User") + +PASS=0; FAIL=0; SKIP=0 +ok() { echo " PASS: $1"; PASS=$((PASS + 1)); } +bad() { echo " FAIL: $1"; FAIL=$((FAIL + 1)); } +skip() { echo " SKIP: $1"; SKIP=$((SKIP + 1)); } +code() { curl -s -o /dev/null -w '%{http_code}' -m 15 "$@"; } + +echo "== preflight ==" +[ "$(code "$CP/health")" = "200" ] || { echo "control plane not up at $CP — start the stack (\`orcabot up\`)"; exit 2; } + +TOKEN="" +[ -f "$TOKEN_FILE" ] && TOKEN=$(tr -d '\r\n' < "$TOKEN_FILE") + +# Baseline: dev headers with NO surface token. If accepted (200), the running +# build predates the gate — SKIP rather than FAIL (don't fail an old stack). +NO_TOKEN_CODE=$(code "${DEV[@]}" "$CP$ENDPOINT") +if [ -z "$TOKEN" ] || [ "$NO_TOKEN_CODE" = "200" ]; then + skip "surface-token enforcement not active (no-token -> $NO_TOKEN_CODE; token file $([ -n "$TOKEN" ] && echo present || echo missing)) — rebuild with the fix to test" + echo; echo "==== $PASS passed, $FAIL failed, $SKIP skipped ====" + exit 0 +fi + +echo "== surface-token gate ==" +# 1. valid token -> 200 +c=$(code "${DEV[@]}" -H "X-Orcabot-Surface: $TOKEN" "$CP$ENDPOINT") +[ "$c" = "200" ] && ok "dev-auth + valid surface token -> 200 (trusted host client)" \ + || bad "valid token expected 200, got $c (token file may be stale vs the running boot)" + +# 2. no token -> 401/403 (the VM-agent case) +case "$NO_TOKEN_CODE" in + 401|403) ok "dev-auth + NO surface token -> $NO_TOKEN_CODE (VM-origin spoof blocked)";; + *) bad "no token expected 401/403, got $NO_TOKEN_CODE";; +esac + +# 3. wrong token -> 401/403 +c=$(code "${DEV[@]}" -H "X-Orcabot-Surface: not-the-real-token-deadbeef" "$CP$ENDPOINT") +case "$c" in + 401|403) ok "dev-auth + WRONG surface token -> $c";; + *) bad "wrong token expected 401/403, got $c";; +esac + +echo +echo "==== $PASS passed, $FAIL failed, $SKIP skipped ====" +[ "$FAIL" -eq 0 ] diff --git a/desktop/workerd/config/workerd.desktop.capnp b/desktop/workerd/config/workerd.desktop.capnp index 7ddc3d07..9e2b1093 100644 --- a/desktop/workerd/config/workerd.desktop.capnp +++ b/desktop/workerd/config/workerd.desktop.capnp @@ -13,7 +13,16 @@ using Workerd = import "/workerd.capnp"; const config :Workerd.Config = ( services = [ (name = "internet", network = ( - allow = ["public", "private", "local"] + # public = internet APIs (OAuth token exchange, provider APIs); local = + # the loopback d1-shim (:9001) + sandbox (:8080). "private" is deliberately + # dropped so a control-plane SSRF can't reach private-LAN hosts + # (10.0.0.0/8, 192.168.0.0/16, …) — nothing here needs it. + allow = ["public", "local"], + # Enable outbound HTTPS from the control-plane worker (OAuth token + # exchange, provider APIs, Resend, etc.). Without tlsOptions, workerd's + # network service is HTTP-only and every server-side fetch to an https:// + # URL fails with "this HttpClient doesn't support HTTPS". + tlsOptions = (trustBrowserCas = true) )), (name = "d1-shim", external = ( address = "127.0.0.1:9001", @@ -35,13 +44,20 @@ const config :Workerd.Config = ( (name = "D1_SHIM", service = "d1-shim"), (name = "D1_SHIM_DEBUG", fromEnvironment = "D1_SHIM_DEBUG"), (name = "DEV_AUTH_ENABLED", fromEnvironment = "DEV_AUTH_ENABLED"), + # Per-boot token that gates dev-auth to the host frontend. The frontend + # sends it as X-Orcabot-Surface; the sandbox VM never has it, so a VM + # process reaching :8787 can't spoof user auth. Empty = not enforced. + (name = "SURFACE_TOKEN", fromEnvironment = "SURFACE_TOKEN"), (name = "ALLOWED_ORIGINS", fromEnvironment = "ALLOWED_ORIGINS"), (name = "FRONTEND_URL", fromEnvironment = "FRONTEND_URL"), # Secrets / crypto (name = "SECRETS_ENCRYPTION_KEY", fromEnvironment = "SECRETS_ENCRYPTION_KEY"), - # OAuth — pass through optionally; empty = feature disabled + # OAuth — pass through optionally; empty = feature disabled. + # Desktop is a PUBLIC OAuth client (embedded client_id, no protectable + # secret) so it uses the PKCE flow — always on here, unset in cloud. + (name = "OAUTH_PUBLIC_CLIENT", text = "true"), (name = "OAUTH_REDIRECT_BASE", fromEnvironment = "OAUTH_REDIRECT_BASE"), (name = "GOOGLE_CLIENT_ID", fromEnvironment = "GOOGLE_CLIENT_ID"), (name = "GOOGLE_CLIENT_SECRET", fromEnvironment = "GOOGLE_CLIENT_SECRET"), diff --git a/frontend/src/app/(app)/dashboards/[id]/page.tsx b/frontend/src/app/(app)/dashboards/[id]/page.tsx index cc24d668..d2b0512b 100644 --- a/frontend/src/app/(app)/dashboards/[id]/page.tsx +++ b/frontend/src/app/(app)/dashboards/[id]/page.tsx @@ -84,7 +84,7 @@ import { UIGuidanceOverlay } from "@/components/ui/UIGuidanceOverlay"; import { getDashboard, createItem, updateItem, deleteItem, createEdge, deleteEdge, getDashboardMetrics, startDashboardBrowser, stopDashboardBrowser, sendUICommandResult, sandboxKeepalive, listPendingEgressApprovals } from "@/lib/api/cloudflare"; import { apiGet } from "@/lib/api/client"; import { trackEvent } from "@/lib/analytics"; -import { API } from "@/config/env"; +import { API, DESKTOP_MODE } from "@/config/env"; import { generateId } from "@/lib/utils"; import type { DashboardItem, Dashboard, Session, DashboardEdge, DashboardItemType } from "@/types/dashboard"; import type { PresenceUser } from "@/types/collaboration"; @@ -189,7 +189,7 @@ const microsoftTools: BlockTool[] = [ ]; // Messaging integrations in their own section -const messagingTools: BlockTool[] = [ +const messagingToolsAll: BlockTool[] = [ { type: "slack", icon: , label: "Slack" }, { type: "discord", icon: , label: "Discord" }, { type: "whatsapp", icon: , label: "WhatsApp" }, @@ -197,6 +197,13 @@ const messagingTools: BlockTool[] = [ // Telegram, Matrix, Google Chat hidden until ready ]; +// Slack, Discord, and X use confidential OAuth client secrets we don't ship in +// the desktop build; WhatsApp needs the Baileys bridge which desktop doesn't +// bundle. Hide them on desktop (cloud keeps them). +const messagingTools: BlockTool[] = messagingToolsAll.filter( + (t) => !(DESKTOP_MODE && ["slack", "discord", "twitter", "whatsapp"].includes(t.type)) +); + const terminalTools: BlockTool[] = [ { type: "terminal", @@ -3911,7 +3918,8 @@ export default function DashboardPage() { ))} - {/* Messaging integrations section */} + {/* Messaging integrations section (hidden when empty, e.g. desktop) */} + {messagingTools.length > 0 && (
+ )} {/* Connect mode */}
diff --git a/frontend/src/app/providers.tsx b/frontend/src/app/providers.tsx index 849dca05..c432b008 100644 --- a/frontend/src/app/providers.tsx +++ b/frontend/src/app/providers.tsx @@ -13,6 +13,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { TooltipProvider } from "@/components/ui/tooltip"; import { Toaster } from "sonner"; import { API, DESKTOP_MODE } from "@/config/env"; +import { ensureSurfaceToken } from "@/lib/tauri-bridge"; import { getAuthHeaders, useAuthStore } from "@/stores/auth-store"; import type { User, SubscriptionInfo } from "@/types"; import { initAnalytics, stopAnalytics, resetQueue } from "@/lib/analytics"; @@ -61,15 +62,18 @@ function AuthBootstrapper() { if (validatedUserRef.current !== userId) { validatedUserRef.current = userId; if (DESKTOP_MODE) { - const authHeaders = getAuthHeaders(); - fetch(API.cloudflare.usersMe, { - headers: { ...authHeaders }, - credentials: "include", - }) + void ensureSurfaceToken() + .then(() => { + const authHeaders = getAuthHeaders(); + return fetch(API.cloudflare.usersMe, { + headers: { ...authHeaders }, + credentials: "include", + }); + }) .then((r) => (r.ok ? r.json() : null)) .then((data: { user?: User; isAdmin?: boolean; subscription?: SubscriptionInfo } | null) => { if (data?.user) { - if (data.user.id !== authHeaders["X-User-ID"] || data.subscription) { + if (data.user.id !== userId || data.subscription) { setUser(data.user, data.isAdmin ?? false, data.subscription); } } @@ -112,7 +116,16 @@ function AuthBootstrapper() { const bootstrap = async () => { // Desktop mode: auto-login as local user, no auth needed if (DESKTOP_MODE) { + // Set the local user first so a slow/failed surface-token fetch can't + // block login. Then load the token (bounded) so the raw bootstrap fetches + // below carry X-Orcabot-Surface — the control plane requires it to honor + // dev-auth, or /auth/dev/session and the /me ID-sync 401 (wrong user → + // empty dashboards). loginDevMode("Desktop User", "desktop@localhost"); + await Promise.race([ + ensureSurfaceToken(), + new Promise((resolve) => setTimeout(resolve, 3000)), + ]); const authHeaders = getAuthHeaders(); try { // Create server-side session diff --git a/frontend/src/components/blocks/CalendarBlock.tsx b/frontend/src/components/blocks/CalendarBlock.tsx index 1eadf4b5..222dfd5c 100644 --- a/frontend/src/components/blocks/CalendarBlock.tsx +++ b/frontend/src/components/blocks/CalendarBlock.tsx @@ -48,7 +48,8 @@ import { type CalendarStatus, type CalendarEvent, } from "@/lib/api/cloudflare"; -import { API } from "@/config/env"; +import { API, DESKTOP_MODE } from "@/config/env"; +import { connectViaBrowser } from "@/lib/oauth-connect"; import type { DashboardItem } from "@/types/dashboard"; import { BlockSettingsFooter } from "./BlockSettingsFooter"; import { HelpButton } from "@/components/help/HelpDialog"; @@ -207,6 +208,22 @@ export function CalendarBlock({ id, data, selected }: NodeProps) { // Connect Calendar const handleConnect = () => { if (!dashboardId) return; + if (DESKTOP_MODE) { + // window.open is a no-op in the Tauri webview — open the OS browser and + // poll for the connection instead of the popup/postMessage handshake. + connectViaBrowser({ + url: `${API.cloudflare.base}/integrations/google/calendar/connect?dashboard_id=${dashboardId}`, + checkConnected: async () => Boolean((await getCalendarIntegration(dashboardId))?.connected), + onConnected: () => { + void (async () => { + try { await setupCalendarMirror(dashboardId); } catch { /* ignore */ } + await loadIntegration(); + await loadEvents(); + })(); + }, + }); + return; + } const connectUrl = `${API.cloudflare.base}/integrations/google/calendar/connect?dashboard_id=${dashboardId}&mode=popup`; const popup = window.open(connectUrl, "calendar-connect", "width=600,height=700"); diff --git a/frontend/src/components/blocks/ContactsBlock.tsx b/frontend/src/components/blocks/ContactsBlock.tsx index 5d25fdb2..5907a820 100644 --- a/frontend/src/components/blocks/ContactsBlock.tsx +++ b/frontend/src/components/blocks/ContactsBlock.tsx @@ -47,7 +47,8 @@ import { type ContactsStatus, type Contact, } from "@/lib/api/cloudflare"; -import { API } from "@/config/env"; +import { API, DESKTOP_MODE } from "@/config/env"; +import { connectViaBrowser } from "@/lib/oauth-connect"; import type { DashboardItem } from "@/types/dashboard"; import { BlockSettingsFooter } from "./BlockSettingsFooter"; import { HelpButton } from "@/components/help/HelpDialog"; @@ -217,6 +218,22 @@ export function ContactsBlock({ id, data, selected }: NodeProps) { // Connect Contacts const handleConnect = () => { if (!dashboardId) return; + if (DESKTOP_MODE) { + // window.open is a no-op in the Tauri webview — open the OS browser and + // poll for the connection instead of the popup/postMessage handshake. + connectViaBrowser({ + url: `${API.cloudflare.base}/integrations/google/contacts/connect?dashboard_id=${dashboardId}`, + checkConnected: async () => Boolean((await getContactsIntegration(dashboardId))?.connected), + onConnected: () => { + void (async () => { + try { await setupContactsMirror(dashboardId); } catch { /* ignore */ } + await loadIntegration(); + await loadContacts(); + })(); + }, + }); + return; + } const connectUrl = `${API.cloudflare.base}/integrations/google/contacts/connect?dashboard_id=${dashboardId}&mode=popup`; const popup = window.open(connectUrl, "contacts-connect", "width=600,height=700"); diff --git a/frontend/src/components/blocks/DiscordBlock.tsx b/frontend/src/components/blocks/DiscordBlock.tsx index d4219924..bf91f377 100644 --- a/frontend/src/components/blocks/DiscordBlock.tsx +++ b/frontend/src/components/blocks/DiscordBlock.tsx @@ -34,7 +34,8 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; -import { API } from "@/config/env"; +import { API, DESKTOP_MODE } from "@/config/env"; +import { connectViaBrowser } from "@/lib/oauth-connect"; import { apiFetch, apiGet } from "@/lib/api/client"; import { DiscordIcon } from "@/components/icons"; import { BlockSettingsFooter } from "./BlockSettingsFooter"; @@ -309,6 +310,24 @@ export function DiscordBlock({ id, data, selected }: NodeProps) { const handleConnect = () => { if (!dashboardId) return; + if (DESKTOP_MODE) { + // window.open is a no-op in the Tauri webview — open the OS browser and + // poll for the connection instead of the popup/postMessage handshake. + connectViaBrowser({ + url: `${API.cloudflare.base}/integrations/discord/connect?dashboard_id=${dashboardId}`, + checkConnected: async () => Boolean((await getDiscordIntegration(dashboardId))?.connected), + onConnected: () => { + void (async () => { + try { + await loadIntegration(); + } catch (err) { + console.error("Failed to load Discord integration after connect:", err); + } + })(); + }, + }); + return; + } const connectUrl = `${API.cloudflare.base}/integrations/discord/connect?dashboard_id=${dashboardId}&mode=popup`; const popup = window.open(connectUrl, "discord-connect", "width=600,height=800"); diff --git a/frontend/src/components/blocks/FormsBlock.tsx b/frontend/src/components/blocks/FormsBlock.tsx index 032f2439..cdb7737d 100644 --- a/frontend/src/components/blocks/FormsBlock.tsx +++ b/frontend/src/components/blocks/FormsBlock.tsx @@ -45,7 +45,8 @@ import { type Form, type FormResponse, } from "@/lib/api/cloudflare"; -import { API } from "@/config/env"; +import { API, DESKTOP_MODE } from "@/config/env"; +import { connectViaBrowser } from "@/lib/oauth-connect"; import type { DashboardItem } from "@/types/dashboard"; import { BlockSettingsFooter } from "./BlockSettingsFooter"; import { HelpButton } from "@/components/help/HelpDialog"; @@ -200,6 +201,21 @@ export function FormsBlock({ id, data, selected }: NodeProps) { // Connect Forms const handleConnect = () => { if (!dashboardId) return; + if (DESKTOP_MODE) { + // window.open is a no-op in the Tauri webview — open the OS browser and + // poll for the connection instead of the popup/postMessage handshake. + connectViaBrowser({ + url: `${API.cloudflare.base}/integrations/google/forms/connect?dashboard_id=${dashboardId}`, + checkConnected: async () => Boolean((await getFormsIntegration(dashboardId))?.connected), + onConnected: () => { + void (async () => { + try { await setupFormsMirror(dashboardId); } catch { /* ignore */ } + await loadIntegration(); + })(); + }, + }); + return; + } const connectUrl = `${API.cloudflare.base}/integrations/google/forms/connect?dashboard_id=${dashboardId}&mode=popup`; const popup = window.open(connectUrl, "forms-connect", "width=600,height=700"); diff --git a/frontend/src/components/blocks/GithubDeviceDialog.tsx b/frontend/src/components/blocks/GithubDeviceDialog.tsx new file mode 100644 index 00000000..6cc37358 --- /dev/null +++ b/frontend/src/components/blocks/GithubDeviceDialog.tsx @@ -0,0 +1,185 @@ +// Copyright 2026 Rob Macrae. All rights reserved. +// SPDX-License-Identifier: LicenseRef-Proprietary +"use client"; + +// REVISION: github-device-dialog-v1 +import * as React from "react"; +import { Loader2, ExternalLink, Copy, Check, AlertCircle } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { startGithubDevice, pollGithubDevice } from "@/lib/api/cloudflare/integrations"; +import { openExternalUrl } from "@/lib/tauri-bridge"; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; + dashboardId?: string; + /** Called once GitHub reports the flow complete (token stored). */ + onConnected: () => void; +} + +type Phase = "starting" | "waiting" | "connected" | "error"; + +/** + * Desktop GitHub connect via the OAuth **device flow** (public client, no secret, + * no redirect): show a user code, the user enters it at github.com/login/device, + * and we poll the control plane until it reports the token stored. + */ +export function GithubDeviceDialog({ open, onOpenChange, dashboardId, onConnected }: Props) { + const [phase, setPhase] = React.useState("starting"); + const [userCode, setUserCode] = React.useState(""); + const [verificationUri, setVerificationUri] = React.useState("https://github.com/login/device"); + const [error, setError] = React.useState(null); + const [copied, setCopied] = React.useState(false); + const stateRef = React.useRef(null); + const pollRef = React.useRef | null>(null); + // Keep the latest callbacks in refs so `begin` stays stable and the start + // effect doesn't re-run (re-requesting a device code) on every parent render. + const onConnectedRef = React.useRef(onConnected); + const onOpenChangeRef = React.useRef(onOpenChange); + onConnectedRef.current = onConnected; + onOpenChangeRef.current = onOpenChange; + + const stopPolling = React.useCallback(() => { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + }, []); + + const begin = React.useCallback(async () => { + stopPolling(); + setPhase("starting"); + setError(null); + setUserCode(""); + try { + const res = await startGithubDevice(dashboardId); + stateRef.current = res.state; + setUserCode(res.user_code); + setVerificationUri(res.verification_uri || "https://github.com/login/device"); + setPhase("waiting"); + const intervalMs = Math.max(2, res.interval || 5) * 1000; + pollRef.current = setInterval(async () => { + const st = stateRef.current; + if (!st) return; + try { + const p = await pollGithubDevice(st); + if (p.status === "complete") { + stopPolling(); + setPhase("connected"); + onConnectedRef.current(); + setTimeout(() => onOpenChangeRef.current(false), 800); + } else if (p.status === "error") { + stopPolling(); + setError( + p.error === "expired" + ? "The code expired — start again." + : p.error === "denied" + ? "Authorization was denied." + : "GitHub authorization failed." + ); + setPhase("error"); + } + // pending / slow_down: keep waiting + } catch { + // transient poll error — keep waiting + } + }, intervalMs); + } catch (e) { + setError(e instanceof Error ? e.message : "Couldn't start GitHub sign-in."); + setPhase("error"); + } + }, [dashboardId, stopPolling]); + + // Start when opened; always clean up the poller when closed/unmounted. + React.useEffect(() => { + if (open) void begin(); + return () => stopPolling(); + }, [open, begin, stopPolling]); + + const copyCode = React.useCallback(() => { + if (!userCode) return; + void navigator.clipboard?.writeText(userCode).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }, [userCode]); + + return ( + { + if (!o) stopPolling(); + onOpenChange(o); + }} + > + + + Connect GitHub + + Enter this code at GitHub to authorize Orcabot. + + + + {phase === "starting" && ( +
+ Starting… +
+ )} + + {(phase === "waiting" || phase === "connected") && ( +
+ + +
+ {phase === "connected" ? ( + <> + Connected! + + ) : ( + <> + Waiting for authorization… + + )} +
+
+ )} + + {phase === "error" && ( +
+
+ + {error} +
+ +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/blocks/GmailBlock.tsx b/frontend/src/components/blocks/GmailBlock.tsx index 656a024d..20a1a20b 100644 --- a/frontend/src/components/blocks/GmailBlock.tsx +++ b/frontend/src/components/blocks/GmailBlock.tsx @@ -53,7 +53,8 @@ import { type GmailMessage, type GmailActionType, } from "@/lib/api/cloudflare"; -import { API } from "@/config/env"; +import { API, DESKTOP_MODE } from "@/config/env"; +import { connectViaBrowser } from "@/lib/oauth-connect"; import type { DashboardItem } from "@/types/dashboard"; import { BlockSettingsFooter } from "./BlockSettingsFooter"; import { HelpButton } from "@/components/help/HelpDialog"; @@ -206,6 +207,22 @@ export function GmailBlock({ id, data, selected }: NodeProps) { // Connect Gmail const handleConnect = () => { if (!dashboardId) return; + if (DESKTOP_MODE) { + // window.open is a no-op in the Tauri webview — open the OS browser and + // poll for the connection instead of the popup/postMessage handshake. + connectViaBrowser({ + url: `${API.cloudflare.base}/integrations/google/gmail/connect?dashboard_id=${dashboardId}`, + checkConnected: async () => Boolean((await getGmailIntegration(dashboardId))?.connected), + onConnected: () => { + void (async () => { + try { await setupGmailMirror(dashboardId); } catch { /* ignore */ } + await loadIntegration(); + await loadMessages(); + })(); + }, + }); + return; + } const connectUrl = `${API.cloudflare.base}/integrations/google/gmail/connect?dashboard_id=${dashboardId}&mode=popup`; const popup = window.open(connectUrl, "gmail-connect", "width=600,height=700"); diff --git a/frontend/src/components/blocks/IntegrationsPanel.tsx b/frontend/src/components/blocks/IntegrationsPanel.tsx index 708b3073..ffece796 100644 --- a/frontend/src/components/blocks/IntegrationsPanel.tsx +++ b/frontend/src/components/blocks/IntegrationsPanel.tsx @@ -49,7 +49,8 @@ import { } from "@/lib/api/cloudflare/integration-policies"; import { PolicyEditorDialog } from "./PolicyEditorDialog"; import { AuditLogViewer } from "./AuditLogViewer"; -import { API } from "@/config/env"; +import { API, DESKTOP_MODE } from "@/config/env"; +import { connectViaBrowser } from "@/lib/oauth-connect"; // OAuth connect URLs by provider function getOAuthConnectUrl(provider: IntegrationProvider, dashboardId: string): string | null { @@ -448,6 +449,25 @@ export const IntegrationsPanel: React.FC = ({ const connectUrl = getOAuthConnectUrl(provider, dashboardId); if (!connectUrl) return; + if (DESKTOP_MODE) { + // window.open is a no-op in the Tauri webview — open the OS browser and + // poll the panel's connection state (the available-integrations query's + // `connected` flag) instead of the popup-close handshake. + connectViaBrowser({ + url: connectUrl.replace(/&mode=popup/, "").replace(/\?mode=popup&?/, "?"), + checkConnected: async () => { + const list = await listAvailableIntegrations(dashboardId, terminalId); + return Boolean(list.find((a) => a.provider === provider)?.connected); + }, + onConnected: () => { + queryClient.invalidateQueries({ + queryKey: ["available-integrations", dashboardId, terminalId], + }); + }, + }); + return; + } + const popup = window.open(connectUrl, `${provider}-connect`, "width=600,height=700"); if (!popup) return; diff --git a/frontend/src/components/blocks/OutlookBlock.tsx b/frontend/src/components/blocks/OutlookBlock.tsx index 2d6f2e48..8858cb38 100644 --- a/frontend/src/components/blocks/OutlookBlock.tsx +++ b/frontend/src/components/blocks/OutlookBlock.tsx @@ -50,7 +50,8 @@ import { type OutlookMessage, type OutlookActionType, } from "@/lib/api/cloudflare"; -import { API } from "@/config/env"; +import { API, DESKTOP_MODE } from "@/config/env"; +import { connectViaBrowser } from "@/lib/oauth-connect"; import { apiGet } from "@/lib/api/client"; import { OutlookIcon } from "@/components/icons/MessagingIcons"; import { BlockSettingsFooter } from "./BlockSettingsFooter"; @@ -262,6 +263,27 @@ export function OutlookBlock({ id, data, selected }: NodeProps) { // Connect Outlook via OAuth popup const handleConnect = () => { if (!dashboardId) return; + if (DESKTOP_MODE) { + // window.open is a no-op in the Tauri webview — open the OS browser and + // poll for the connection instead of the popup/postMessage handshake. + connectViaBrowser({ + url: `${API.cloudflare.base}/integrations/outlook/connect?dashboard_id=${dashboardId}`, + checkConnected: async () => Boolean((await getOutlookIntegration(dashboardId))?.connected), + onConnected: () => { + void (async () => { + try { + await setupOutlookMirror(dashboardId); + await loadIntegration(); + await loadMessages(); + } catch (err) { + console.error("Failed to set up Outlook mirror:", err); + await loadIntegration(); + } + })(); + }, + }); + return; + } const connectUrl = `${API.cloudflare.base}/integrations/outlook/connect?dashboard_id=${dashboardId}&mode=popup`; const popup = window.open(connectUrl, "outlook-connect", "width=600,height=700"); diff --git a/frontend/src/components/blocks/OutlookCalendarBlock.tsx b/frontend/src/components/blocks/OutlookCalendarBlock.tsx index 3a23734e..de9cc735 100644 --- a/frontend/src/components/blocks/OutlookCalendarBlock.tsx +++ b/frontend/src/components/blocks/OutlookCalendarBlock.tsx @@ -32,7 +32,8 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; -import { API } from "@/config/env"; +import { API, DESKTOP_MODE } from "@/config/env"; +import { connectViaBrowser } from "@/lib/oauth-connect"; import { apiFetch, apiGet } from "@/lib/api/client"; import { OutlookCalendarIcon } from "@/components/icons/MessagingIcons"; import { BlockSettingsFooter } from "./BlockSettingsFooter"; @@ -184,6 +185,20 @@ export function OutlookCalendarBlock({ id, data, selected }: NodeProps { if (!dashboardId) return; + if (DESKTOP_MODE) { + // window.open is a no-op in the Tauri webview — open the OS browser and + // poll for the connection instead of the popup/postMessage handshake. + connectViaBrowser({ + url: `${API.cloudflare.base}/integrations/outlook/calendar/connect?dashboard_id=${dashboardId}`, + checkConnected: async () => Boolean((await getOutlookCalendarIntegration(dashboardId))?.connected), + onConnected: () => { + void (async () => { + await loadIntegration(); + })(); + }, + }); + return; + } const connectUrl = `${API.cloudflare.base}/integrations/outlook/calendar/connect?dashboard_id=${dashboardId}&mode=popup`; const popup = window.open(connectUrl, "outlook-calendar-connect", "width=600,height=700"); diff --git a/frontend/src/components/blocks/SheetsBlock.tsx b/frontend/src/components/blocks/SheetsBlock.tsx index 4aceb96e..78b6bc9b 100644 --- a/frontend/src/components/blocks/SheetsBlock.tsx +++ b/frontend/src/components/blocks/SheetsBlock.tsx @@ -44,7 +44,8 @@ import { type Spreadsheet, type SheetValues, } from "@/lib/api/cloudflare"; -import { API } from "@/config/env"; +import { API, DESKTOP_MODE } from "@/config/env"; +import { connectViaBrowser } from "@/lib/oauth-connect"; import type { DashboardItem } from "@/types/dashboard"; import { BlockSettingsFooter } from "./BlockSettingsFooter"; import { HelpButton } from "@/components/help/HelpDialog"; @@ -219,6 +220,21 @@ export function SheetsBlock({ id, data, selected }: NodeProps) { // Connect Sheets const handleConnect = () => { if (!dashboardId) return; + if (DESKTOP_MODE) { + // window.open is a no-op in the Tauri webview — open the OS browser and + // poll for the connection instead of the popup/postMessage handshake. + connectViaBrowser({ + url: `${API.cloudflare.base}/integrations/google/sheets/connect?dashboard_id=${dashboardId}`, + checkConnected: async () => Boolean((await getSheetsIntegration(dashboardId))?.connected), + onConnected: () => { + void (async () => { + try { await setupSheetsMirror(dashboardId); } catch { /* ignore */ } + await loadIntegration(); + })(); + }, + }); + return; + } const connectUrl = `${API.cloudflare.base}/integrations/google/sheets/connect?dashboard_id=${dashboardId}&mode=popup`; const popup = window.open(connectUrl, "sheets-connect", "width=600,height=700"); diff --git a/frontend/src/components/blocks/SlackBlock.tsx b/frontend/src/components/blocks/SlackBlock.tsx index a63affad..4c210847 100644 --- a/frontend/src/components/blocks/SlackBlock.tsx +++ b/frontend/src/components/blocks/SlackBlock.tsx @@ -35,7 +35,8 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; -import { API } from "@/config/env"; +import { API, DESKTOP_MODE } from "@/config/env"; +import { connectViaBrowser } from "@/lib/oauth-connect"; import { apiFetch, apiGet } from "@/lib/api/client"; import type { DashboardItem } from "@/types/dashboard"; import { BlockSettingsFooter } from "./BlockSettingsFooter"; @@ -333,6 +334,24 @@ export function SlackBlock({ id, data, selected }: NodeProps) { // Connect Slack const handleConnect = () => { if (!dashboardId) return; + if (DESKTOP_MODE) { + // window.open is a no-op in the Tauri webview — open the OS browser and + // poll for the connection instead of the popup/postMessage handshake. + connectViaBrowser({ + url: `${API.cloudflare.base}/integrations/slack/connect?dashboard_id=${dashboardId}`, + checkConnected: async () => Boolean((await getSlackIntegration(dashboardId))?.connected), + onConnected: () => { + void (async () => { + try { + await loadIntegration(); + } catch (err) { + console.error("Failed to load Slack integration after connect:", err); + } + })(); + }, + }); + return; + } const connectUrl = `${API.cloudflare.base}/integrations/slack/connect?dashboard_id=${dashboardId}&mode=popup`; const popup = window.open(connectUrl, "slack-connect", "width=600,height=700"); diff --git a/frontend/src/components/blocks/TeamsBlock.tsx b/frontend/src/components/blocks/TeamsBlock.tsx index a6187968..cee30b6a 100644 --- a/frontend/src/components/blocks/TeamsBlock.tsx +++ b/frontend/src/components/blocks/TeamsBlock.tsx @@ -34,7 +34,8 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { cn } from "@/lib/utils"; -import { API } from "@/config/env"; +import { API, DESKTOP_MODE } from "@/config/env"; +import { connectViaBrowser } from "@/lib/oauth-connect"; import { apiFetch, apiGet } from "@/lib/api/client"; import { TeamsIcon } from "@/components/icons"; import { BlockSettingsFooter } from "./BlockSettingsFooter"; @@ -365,6 +366,20 @@ export function TeamsBlock({ id, data, selected }: NodeProps) { const handleConnectOAuth = () => { if (!dashboardId) return; const base = API.cloudflare.base.replace(/\/$/, ""); + if (DESKTOP_MODE) { + // window.open is a no-op in the Tauri webview — open the OS browser and + // poll for the connection instead of the popup/postMessage handshake. + connectViaBrowser({ + url: `${base}/integrations/teams/connect?dashboard_id=${dashboardId}`, + checkConnected: async () => Boolean((await getTeamsIntegration(dashboardId))?.connected), + onConnected: () => { + void (async () => { + await loadIntegration(); + })(); + }, + }); + return; + } const url = `${base}/integrations/teams/connect?dashboard_id=${dashboardId}&mode=popup`; const popup = window.open(url, "teams-auth", "width=600,height=700"); if (!popup) return; diff --git a/frontend/src/components/blocks/WorkspaceBlock.tsx b/frontend/src/components/blocks/WorkspaceBlock.tsx index 794bd8a1..0619f97c 100644 --- a/frontend/src/components/blocks/WorkspaceBlock.tsx +++ b/frontend/src/components/blocks/WorkspaceBlock.tsx @@ -76,7 +76,9 @@ import { BlockSettingsFooter } from "./BlockSettingsFooter"; import { HelpButton } from "@/components/help/HelpDialog"; import { workspaceDoc } from "@/docs/content/workspace"; import { useAuthStore } from "@/stores/auth-store"; -import { API, DEV_MODE_ENABLED } from "@/config/env"; +import { API, DEV_MODE_ENABLED, DESKTOP_MODE } from "@/config/env"; +import { connectViaBrowser } from "@/lib/oauth-connect"; +import { GithubDeviceDialog } from "./GithubDeviceDialog"; // Module-level cache to prevent integration reload storms across component remounts // Tracks dashboardId -> timestamp of last load @@ -196,6 +198,7 @@ export function WorkspaceBlock({ id, data, selected }: NodeProps) const [driveSyncing, setDriveSyncing] = React.useState(false); const [drivePickerOpen, setDrivePickerOpen] = React.useState(false); const [githubIntegration, setGithubIntegration] = React.useState(null); + const [githubDeviceOpen, setGithubDeviceOpen] = React.useState(false); const [githubStatus, setGithubStatus] = React.useState(null); const [githubSyncing, setGithubSyncing] = React.useState(false); const [githubPickerOpen, setGithubPickerOpen] = React.useState(false); @@ -253,6 +256,25 @@ export function WorkspaceBlock({ id, data, selected }: NodeProps) const handleDriveConnect = React.useCallback(() => { if (!user) return; + if (DESKTOP_MODE) { + const connectUrl = new URL(`${API.cloudflare.base}/integrations/google/drive/connect`); + connectUrl.searchParams.set("user_id", user.id); + connectUrl.searchParams.set("user_email", user.email); + connectUrl.searchParams.set("user_name", user.name); + if (data.dashboardId) { + connectUrl.searchParams.set("dashboard_id", data.dashboardId); + } + connectViaBrowser({ + url: connectUrl.toString(), + checkConnected: async () => + Boolean((await getGoogleDriveIntegration(data.dashboardId))?.connected), + onConnected: () => { + void loadDriveIntegration(); + setDrivePickerOpen(true); + }, + }); + return; + } const url = new URL(`${API.cloudflare.base}/integrations/google/drive/connect`); url.searchParams.set("user_id", user.id); url.searchParams.set("user_email", user.email); @@ -274,6 +296,12 @@ export function WorkspaceBlock({ id, data, selected }: NodeProps) const handleGithubConnect = React.useCallback(() => { if (!user) return; + // Desktop is a public OAuth client → GitHub uses the device flow (no secret, + // no redirect) via a code dialog instead of the popup redirect. + if (DESKTOP_MODE) { + setGithubDeviceOpen(true); + return; + } const url = new URL(`${API.cloudflare.base}/integrations/github/connect`); url.searchParams.set("mode", "popup"); if (data.dashboardId) { @@ -292,6 +320,24 @@ export function WorkspaceBlock({ id, data, selected }: NodeProps) const handleBoxConnect = React.useCallback(() => { if (!user) return; + if (DESKTOP_MODE) { + const connectUrl = new URL(`${API.cloudflare.base}/integrations/box/connect`); + if (data.dashboardId) { + connectUrl.searchParams.set("dashboard_id", data.dashboardId); + } + connectViaBrowser({ + url: connectUrl.toString(), + checkConnected: async () => + Boolean((await getBoxIntegration(data.dashboardId))?.connected), + onConnected: () => { + void loadBoxIntegration(); + setBoxPickerOpen(true); + setBoxPath([]); + void loadBoxFolders("0"); + }, + }); + return; + } const url = new URL(`${API.cloudflare.base}/integrations/box/connect`); url.searchParams.set("mode", "popup"); if (data.dashboardId) { @@ -310,6 +356,24 @@ export function WorkspaceBlock({ id, data, selected }: NodeProps) const handleOnedriveConnect = React.useCallback(() => { if (!user) return; + if (DESKTOP_MODE) { + const connectUrl = new URL(`${API.cloudflare.base}/integrations/onedrive/connect`); + if (data.dashboardId) { + connectUrl.searchParams.set("dashboard_id", data.dashboardId); + } + connectViaBrowser({ + url: connectUrl.toString(), + checkConnected: async () => + Boolean((await getOnedriveIntegration(data.dashboardId))?.connected), + onConnected: () => { + void loadOnedriveIntegration(); + setOnedrivePickerOpen(true); + setOnedrivePath([]); + void loadOnedriveFolders("root"); + }, + }); + return; + } const url = new URL(`${API.cloudflare.base}/integrations/onedrive/connect`); url.searchParams.set("mode", "popup"); if (data.dashboardId) { @@ -1467,7 +1531,8 @@ export function WorkspaceBlock({ id, data, selected }: NodeProps) GitHub )} - {isBoxConnected ? ( + {/* Box uses a confidential secret we don't ship on desktop → hidden there */} + {!DESKTOP_MODE && (isBoxConnected ? ( - )} + ))} {isOnedriveConnected ? ( @@ -1615,6 +1680,16 @@ export function WorkspaceBlock({ id, data, selected }: NodeProps) )} + { + void loadGithubIntegration(); + setGithubPickerOpen(true); + void loadGithubRepos(); + }} + /> GitHub diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts index 909771cc..47dc9cda 100644 --- a/frontend/src/lib/api/client.ts +++ b/frontend/src/lib/api/client.ts @@ -3,6 +3,7 @@ // REVISION: api-client-v2-error-body-single-read import { getAuthHeaders } from "@/stores/auth-store"; +import { ensureSurfaceToken } from "@/lib/tauri-bridge"; import { trackEvent } from "@/lib/analytics"; const API_CLIENT_REVISION = "api-client-v2-error-body-single-read"; @@ -71,6 +72,10 @@ export async function apiFetch( } } + // On desktop, ensure the surface token is loaded before we build auth headers, + // so the very first authed request already carries X-Orcabot-Surface (the + // control plane requires it to honor dev-auth). + await ensureSurfaceToken(); const authHeaders = getAuthHeaders(); const fetchPromise = (async (): Promise => { diff --git a/frontend/src/lib/api/cloudflare/integrations.ts b/frontend/src/lib/api/cloudflare/integrations.ts index d641447e..568fda94 100644 --- a/frontend/src/lib/api/cloudflare/integrations.ts +++ b/frontend/src/lib/api/cloudflare/integrations.ts @@ -223,6 +223,38 @@ export async function getGithubIntegration( return apiGet(url.toString()); } +export interface GithubDeviceStart { + state: string; + user_code: string; + verification_uri: string; + expires_in: number; + interval: number; +} + +/** Desktop public-client GitHub connect (device flow): start → returns a user + * code to enter at github.com/login/device (no secret, no redirect). */ +export async function startGithubDevice( + dashboardId?: string +): Promise { + return apiPost( + `${API.cloudflare.base}/integrations/github/device/start`, + { dashboard_id: dashboardId ?? null } + ); +} + +export interface GithubDevicePoll { + status: "pending" | "slow_down" | "complete" | "error"; + error?: string; +} + +/** Poll the GitHub device flow until it returns complete/error. */ +export async function pollGithubDevice(state: string): Promise { + return apiPost( + `${API.cloudflare.base}/integrations/github/device/poll`, + { state } + ); +} + export async function listGithubRepos(): Promise<{ connected: boolean; repos: GithubRepo[]; error?: string }> { return apiGet<{ connected: boolean; repos: GithubRepo[]; error?: string }>(API.cloudflare.githubRepos); } diff --git a/frontend/src/lib/oauth-connect.ts b/frontend/src/lib/oauth-connect.ts new file mode 100644 index 00000000..5317dc26 --- /dev/null +++ b/frontend/src/lib/oauth-connect.ts @@ -0,0 +1,114 @@ +// Copyright 2026 Rob Macrae. All rights reserved. +// SPDX-License-Identifier: LicenseRef-Proprietary +"use client"; + +import { openExternalUrl, ensureSurfaceToken, onAppFocus } from "@/lib/tauri-bridge"; +import { getAuthHeaders } from "@/stores/auth-store"; + +export interface BrowserOAuthConnect { + /** The provider connect URL (omit mode=popup on desktop — the callback shows a + * plain success page since there's no popup to postMessage back to). */ + url: string; + /** Poll target: resolves true once the provider is connected. */ + checkConnected: () => Promise; + /** Called once when the connection is detected. */ + onConnected: () => void; + intervalMs?: number; + timeoutMs?: number; +} + +/** + * Desktop-safe OAuth connect: open the provider URL in the OS default browser + * and poll the integration status until it reports connected. This replaces the + * `window.open` popup + `postMessage`/popup-close handshake, which can't work in + * the Tauri webview (window.open is a no-op, and the OS browser can't postMessage + * back to the app). Returns a cancel function. + */ +export function connectViaBrowser(opts: BrowserOAuthConnect): () => void { + const { + url, + checkConnected, + onConnected, + intervalMs = 2500, + timeoutMs = 300000, + } = opts; + + // A top-level browser navigation can't send dev-auth / surface HEADERS, so + // pass identity + surface token as query params (the control plane's dev-auth + // and surface gate both accept the query-param form). Ensure the token is + // loaded first so it's present. + void (async () => { + await ensureSurfaceToken(); + let target = url; + try { + const h = getAuthHeaders(); + const u = new URL(url); + if (h["X-User-ID"]) u.searchParams.set("user_id", h["X-User-ID"]); + if (h["X-User-Email"]) u.searchParams.set("user_email", h["X-User-Email"]); + if (h["X-User-Name"]) u.searchParams.set("user_name", h["X-User-Name"]); + if (h["X-Orcabot-Surface"]) u.searchParams.set("surface", h["X-Orcabot-Surface"]); + target = u.toString(); + } catch { + /* fall back to the plain url */ + } + void openExternalUrl(target); + })(); + + const start = Date.now(); + let stopped = false; + let timer: ReturnType | null = null; + let unlistenTauriFocus: (() => void) | null = null; + + const cleanup = () => { + stopped = true; + if (timer) { + clearInterval(timer); + timer = null; + } + if (unlistenTauriFocus) { + unlistenTauriFocus(); + unlistenTauriFocus = null; + } + if (typeof window !== "undefined") { + window.removeEventListener("focus", onFocus); + document.removeEventListener("visibilitychange", onVisibility); + } + }; + + const check = async () => { + if (stopped) return; + if (Date.now() - start > timeoutMs) { + cleanup(); + return; + } + try { + if (await checkConnected()) { + cleanup(); + onConnected(); + } + } catch { + /* transient — keep polling */ + } + }; + + // Re-check the moment the app regains focus — the OS browser is foreground + // while the user authorizes, and background webviews throttle setInterval. + const onFocus = () => void check(); + const onVisibility = () => { + if (typeof document !== "undefined" && !document.hidden) void check(); + }; + + timer = setInterval(() => void check(), intervalMs); + if (typeof window !== "undefined") { + window.addEventListener("focus", onFocus); + document.addEventListener("visibilitychange", onVisibility); + } + // Native Tauri focus event — fires reliably when the app becomes active again + // after the OS browser, unlike the webview's own focus event. + void onAppFocus(() => void check()).then((unlisten) => { + if (stopped) unlisten(); + else unlistenTauriFocus = unlisten; + }); + + return cleanup; +} diff --git a/frontend/src/lib/tauri-bridge.ts b/frontend/src/lib/tauri-bridge.ts index 108293b1..33044f28 100644 --- a/frontend/src/lib/tauri-bridge.ts +++ b/frontend/src/lib/tauri-bridge.ts @@ -102,6 +102,83 @@ export async function switchToCli(): Promise { return true; } +// ---- Desktop surface token ---- +// Per-boot token that gates dev-auth to the trusted host frontend. Fetched once +// from the Tauri host and cached; the control plane requires the matching +// X-Orcabot-Surface header on desktop, so a process in the sandbox VM (which +// can't reach this command) can't spoof dev-auth. See desktop/CLAUDE.md. +let cachedSurfaceToken: string | null = null; +let surfaceTokenPromise: Promise | null = null; + +async function fetchSurfaceToken(): Promise { + const invoke = await getTauriInvoke(); + if (!invoke) return null; + try { + const t = (await invoke("get_surface_token")) as string; + return typeof t === "string" && t ? t : null; + } catch { + return null; + } +} + +/** Fetch + cache the surface token once. Await before making authed requests. */ +export async function ensureSurfaceToken(): Promise { + if (cachedSurfaceToken) return cachedSurfaceToken; + if (!DESKTOP_MODE) return null; + if (!surfaceTokenPromise) { + surfaceTokenPromise = fetchSurfaceToken().then((t) => { + cachedSurfaceToken = t; + return t; + }); + } + return surfaceTokenPromise; +} + +/** Synchronously read the cached surface token (null until ensureSurfaceToken resolves). */ +export function getCachedSurfaceToken(): string | null { + return cachedSurfaceToken; +} + +/** + * Open an external URL. On desktop, opens the OS default browser (window.open is + * a no-op inside the Tauri webview); on web, falls back to window.open. Used for + * OAuth connect flows. + */ +export async function openExternalUrl(url: string): Promise { + const invoke = await getTauriInvoke(); + if (invoke) { + try { + await invoke("open_url", { url }); + return; + } catch { + /* fall through to window.open */ + } + } + if (typeof window !== "undefined") { + window.open(url, "_blank", "noopener,noreferrer"); + } +} + +/** + * Subscribe to the Tauri window regaining focus (the app becoming active again + * after the OS browser). More reliable than the webview's own `focus` event + * across an OS app-switch. Returns an unsubscribe function; no-op off desktop. + */ +export async function onAppFocus(cb: () => void): Promise<() => void> { + if (!DESKTOP_MODE || typeof window === "undefined") return () => {}; + try { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const listen = (window as any).__TAURI__?.event?.listen; + if (typeof listen === "function") { + const unlisten = await listen("tauri://focus", () => cb()); + return typeof unlisten === "function" ? unlisten : () => {}; + } + } catch { + /* fall through — the interval + window focus fallback still runs */ + } + return () => {}; +} + /** Open a native folder picker dialog. Returns the selected path or null if cancelled. */ export async function pickFolder(): Promise { if (!DESKTOP_MODE) return null; diff --git a/frontend/src/stores/auth-store.ts b/frontend/src/stores/auth-store.ts index 4b58ec3d..26bffcfe 100644 --- a/frontend/src/stores/auth-store.ts +++ b/frontend/src/stores/auth-store.ts @@ -7,6 +7,14 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import type { User, SubscriptionInfo } from "@/types"; import { generateId } from "@/lib/utils"; +import { getCachedSurfaceToken } from "@/lib/tauri-bridge"; + +/** On desktop, the control plane only honors dev-auth for requests carrying the + * per-boot surface token, so include it when present (blocks VM-origin spoofing). */ +function surfaceHeader(): Record { + const t = getCachedSurfaceToken(); + return t ? { "X-Orcabot-Surface": t } : {}; +} interface AuthState { user: User | null; @@ -155,6 +163,7 @@ export function useAuthHeaders(): Record { "X-User-ID": user.id, "X-User-Email": user.email, "X-User-Name": user.name, + ...surfaceHeader(), }; } @@ -172,5 +181,6 @@ export function getAuthHeaders(): Record { "X-User-ID": state.user.id, "X-User-Email": state.user.email, "X-User-Name": state.user.name, + ...surfaceHeader(), }; }