From fb5975c184362f07ecc1294de6b4a296e999fe19 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 6 Jul 2026 01:08:45 +0100 Subject: [PATCH 01/21] feat(oauth): desktop public-client PKCE flow for Google integrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A downloaded desktop app is a public OAuth client — it ships an embedded client_id and can't protect a confidential secret. Add PKCE (RFC 7636) to all six Google connect/callback pairs (Drive, Gmail, Calendar, Contacts, Sheets, Forms): generate a verifier, put the S256 challenge on the auth URL, carry the verifier in the existing oauth_states metadata (no schema change), and replay it at token exchange. Gated behind OAUTH_PUBLIC_CLIENT, baked to "true" in workerd.desktop.capnp and left unset in cloud — so the cloud confidential (client_secret) flow is unchanged. Shared helpers usePublicClient/pkceChallengeForState/ applyPkceVerifier keep the per-provider edits minimal. Next: GitHub device flow; bake the real Desktop client_id into the build. REVISION: oauth-pkce-v1 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- controlplane/src/integrations/handler.ts | 107 ++++++++++++++++--- controlplane/src/types.ts | 2 + desktop/workerd/config/workerd.desktop.capnp | 5 +- 3 files changed, 101 insertions(+), 13 deletions(-) diff --git a/controlplane/src/integrations/handler.ts b/controlplane/src/integrations/handler.ts index cba7692a..2c362a29 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.'); @@ -1169,10 +1210,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 +1229,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 +1270,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', { @@ -5226,10 +5274,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 +5293,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 +5334,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 +6494,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 +6513,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 +6554,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 +7444,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 +7463,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 +7504,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 +8414,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 +8433,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 +8474,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 +9123,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 +9142,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 +9183,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', diff --git a/controlplane/src/types.ts b/controlplane/src/types.ts index 4107ed31..11566966 100644 --- a/controlplane/src/types.ts +++ b/controlplane/src/types.ts @@ -60,6 +60,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/workerd/config/workerd.desktop.capnp b/desktop/workerd/config/workerd.desktop.capnp index 7ddc3d07..caa18b92 100644 --- a/desktop/workerd/config/workerd.desktop.capnp +++ b/desktop/workerd/config/workerd.desktop.capnp @@ -41,7 +41,10 @@ const config :Workerd.Config = ( # 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"), From 4c5193a5779b20bf38d348c83c6b75ff01916bc3 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 6 Jul 2026 22:14:32 +0100 Subject: [PATCH 02/21] build(desktop): bake OAuth client creds into the bundled capnp at build time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A downloaded app can't read host env vars, so provider client IDs/secrets must ship inside the app. When GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET (etc.) are set at build time, rewrite the matching `fromEnvironment` bindings to literal `text` values in the STAGED capnp only — source stays clean, so nothing lands in the public repo (which also keeps Google from auto-revoking an exposed secret). No-op when the vars are unset. GOOGLE_CLIENT_ID=... GOOGLE_CLIENT_SECRET=... cargo tauri build Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- desktop/scripts/build-desktop-resources.sh | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/desktop/scripts/build-desktop-resources.sh b/desktop/scripts/build-desktop-resources.sh index c0235158..4eb40a69 100755 --- a/desktop/scripts/build-desktop-resources.sh +++ b/desktop/scripts/build-desktop-resources.sh @@ -38,6 +38,32 @@ 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" +for _oauth_var in GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET GOOGLE_API_KEY GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET; 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 From 6a18b4a5da321343c4e1f7eb512011df4e7c63a0 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 7 Jul 2026 08:47:22 +0100 Subject: [PATCH 03/21] feat(oauth): GitHub device flow for the desktop public client GitHub OAuth has no PKCE and its client secret is genuinely confidential, so a downloaded app can't use the redirect+secret flow. Add the OAuth device flow (RFC 8628): POST github/device/start returns a user_code + verification_uri (device_code stashed under a state handle), and github/device/poll exchanges grant_type=device_code, returning pending / slow_down / complete / error and persisting the token to user_integrations exactly like the redirect callback. start is gated to the public-client build; cloud keeps the redirect flow. Needs only GITHUB_CLIENT_ID (no secret). REVISION: github-device-flow-v1 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- controlplane/src/index.ts | 2 + controlplane/src/integrations/handler.ts | 151 +++++++++++++++++++++++ 2 files changed, 153 insertions(+) diff --git a/controlplane/src/index.ts b/controlplane/src/index.ts index 83fa5036..9d888101 100644 --- a/controlplane/src/index.ts +++ b/controlplane/src/index.ts @@ -2011,6 +2011,8 @@ 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/integrations/handler.ts b/controlplane/src/integrations/handler.ts index 2c362a29..eee46d78 100644 --- a/controlplane/src/integrations/handler.ts +++ b/controlplane/src/integrations/handler.ts @@ -4721,6 +4721,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, From 5febe2e4a4d996f5843e04046929085e47d49995 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 7 Jul 2026 18:10:13 +0100 Subject: [PATCH 04/21] fix(desktop): enable control-plane outbound HTTPS; tighten SSRF surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Primary fix: the workerd `internet` network service had no tlsOptions, so it was HTTP-only — every server-side fetch to an https:// URL failed with "this HttpClient doesn't support HTTPS". That silently broke ALL outbound integration calls on desktop (OAuth token exchange, GitHub device flow, Gmail/Drive APIs, Resend). Add `tlsOptions = (trustBrowserCas = true)`. Hardening (surfaced while reviewing the egress implications): - Drop "private" from the network allow-list (keep public + local): the control plane only reaches loopback (d1-shim, sandbox) + public APIs, so a control-plane SSRF can no longer reach private-LAN hosts. - /embed-check (a server-side fetch of a caller-supplied URL) now rejects PAT-authed callers via rejectPatAuth, alongside its existing private-hostname block. Note: this does NOT widen the sandbox egress monitor (that governs VM PTY traffic via iptables; the control plane was never in its path). REVISION: desktop-workerd-tls-v1 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- controlplane/src/index.ts | 5 +++++ desktop/workerd/config/workerd.desktop.capnp | 11 ++++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/controlplane/src/index.ts b/controlplane/src/index.ts index 9d888101..c579b7e8 100644 --- a/controlplane/src/index.ts +++ b/controlplane/src/index.ts @@ -1267,6 +1267,11 @@ async function handleRequest(request: Request, env: EnvWithBindings, ctx: Pick Date: Tue, 7 Jul 2026 19:31:00 +0100 Subject: [PATCH 05/21] feat(desktop): gate dev-auth behind a per-boot surface token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the desktop trust-boundary gap: dev-auth is header-spoofable and a process inside the sandbox VM can reach the control plane at :8787 (the guest->host bridge; localhost bypasses the egress proxy), so it could impersonate the user against user-scoped endpoints. Fix: the desktop app generates a per-boot SURFACE_TOKEN, passes it to the control-plane worker, and exposes it to the GUI webview via a new get_surface_token Tauri command. The frontend sends it as X-Orcabot-Surface on control-plane calls (fetched+cached in tauri-bridge, ensured before the first authed request), and the control plane only honors dev-auth when the header matches (devAuthSurfaceTrusted). The VM never gets the token and can't reach the frontend (:8788) to scrape it, so it's confined to the token-authed paths it should use (gateway PTY token, /internal token). When SURFACE_TOKEN is unset (cloud, local dev, older builds) enforcement is off — behavior unchanged. Desktop-only; prod runs DEV_AUTH_ENABLED=false. REVISION: surface-token-v1 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- controlplane/src/auth/middleware.ts | 20 +++++++++-- controlplane/src/types.ts | 3 ++ desktop/app/src-tauri/src/commands.rs | 8 +++++ desktop/app/src-tauri/src/main.rs | 26 ++++++++++++++ desktop/workerd/config/workerd.desktop.capnp | 4 +++ frontend/src/lib/api/client.ts | 5 +++ frontend/src/lib/tauri-bridge.ts | 37 ++++++++++++++++++++ frontend/src/stores/auth-store.ts | 10 ++++++ 8 files changed, 111 insertions(+), 2 deletions(-) diff --git a/controlplane/src/auth/middleware.ts b/controlplane/src/auth/middleware.ts index c42dfa2b..b5af0e03 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,18 @@ 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; + return request.headers.get('X-Orcabot-Surface') === expected; +} + // Production: Cloudflare Access JWT validation async function authenticateWithCfAccеss( request: Request, diff --git a/controlplane/src/types.ts b/controlplane/src/types.ts index 11566966..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; diff --git a/desktop/app/src-tauri/src/commands.rs b/desktop/app/src-tauri/src/commands.rs index 5551900d..1a38962d 100644 --- a/desktop/app/src-tauri/src/commands.rs +++ b/desktop/app/src-tauri/src/commands.rs @@ -608,6 +608,14 @@ 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() +} + #[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..e8a07925 100644 --- a/desktop/app/src-tauri/src/main.rs +++ b/desktop/app/src-tauri/src/main.rs @@ -120,6 +120,28 @@ 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() + }) +} + /// 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); @@ -361,6 +383,9 @@ impl DesktopServices { ("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 +793,7 @@ fn main() { commands::import_folder, commands::switch_to_cli, commands::quit_app, + commands::get_surface_token, ]) .setup(|app| { let services = Arc::new(DesktopServices::new()); diff --git a/desktop/workerd/config/workerd.desktop.capnp b/desktop/workerd/config/workerd.desktop.capnp index 6e3f282a..9e2b1093 100644 --- a/desktop/workerd/config/workerd.desktop.capnp +++ b/desktop/workerd/config/workerd.desktop.capnp @@ -44,6 +44,10 @@ 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"), 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/tauri-bridge.ts b/frontend/src/lib/tauri-bridge.ts index 108293b1..b1e92413 100644 --- a/frontend/src/lib/tauri-bridge.ts +++ b/frontend/src/lib/tauri-bridge.ts @@ -102,6 +102,43 @@ 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 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(), }; } From 3a8fb03e905fd95dc716ff883eb97ce6fcf7d6b3 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 7 Jul 2026 19:35:11 +0100 Subject: [PATCH 06/21] feat(desktop): equip the orcabot CLI with the surface token; document boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The surface-token gate would also 401 the trusted host clients that use dev-auth (the orcabot CLI, scripts), not just the VM. Fix: orcabot-desktop also writes the token to a host-only file (data_dir/surface-token, 0600). The app-data dir isn't shared into the sandbox VM (only /workspace is), so a VM process still can't read it, but the CLI can — apply_auth now sends X-Orcabot-Surface for local targets. Also document the trust boundary in desktop/CLAUDE.md. REVISION: surface-token-v1 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- desktop/CLAUDE.md | 24 ++++++++++++++++++++ desktop/app/src-tauri/src/bin/orcabot.rs | 28 ++++++++++++++++++++---- desktop/app/src-tauri/src/main.rs | 18 +++++++++++++++ 3 files changed, 66 insertions(+), 4 deletions(-) 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/main.rs b/desktop/app/src-tauri/src/main.rs index e8a07925..f564c1ec 100644 --- a/desktop/app/src-tauri/src/main.rs +++ b/desktop/app/src-tauri/src/main.rs @@ -142,6 +142,21 @@ pub fn surface_token() -> &'static str { }) } +/// 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); @@ -377,6 +392,9 @@ 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), From 11447251d2e4c05f4fba51fa9656e6ae466ca7a5 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 7 Jul 2026 19:41:31 +0100 Subject: [PATCH 07/21] test(desktop): security smoke for the dev-auth surface-token gate Proves the trust boundary: dev-auth + valid surface token -> 200, but dev headers with NO token (the VM-agent case) or a WRONG token -> 401. Reads the host-only token file; SKIPs cleanly on builds that predate the gate. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- desktop/tests/security-smoke.sh | 67 +++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100755 desktop/tests/security-smoke.sh 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 ] From 4ce444a4e49dee2a3be40908d0acb08eacf751b7 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 7 Jul 2026 19:47:33 +0100 Subject: [PATCH 08/21] build(desktop): sync resources into target/release/resources after a rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dev orcabot-desktop binary loads resources from target/release/resources (populated by cargo build), but build-desktop-resources.sh only wrote to the source app/src-tauri/resources — so a standalone resources rebuild silently had no effect until a full cargo build. Sync the two at the end (only when the target dir exists), so `build-desktop-resources.sh` + `orcabot up` takes effect. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- desktop/scripts/build-desktop-resources.sh | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/desktop/scripts/build-desktop-resources.sh b/desktop/scripts/build-desktop-resources.sh index 4eb40a69..5370cb20 100755 --- a/desktop/scripts/build-desktop-resources.sh +++ b/desktop/scripts/build-desktop-resources.sh @@ -331,3 +331,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 From ee9d40e8be7cddbdcedc564762283574d9a8967a Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 7 Jul 2026 19:58:10 +0100 Subject: [PATCH 09/21] feat(frontend): GitHub device-code modal for desktop connect On desktop (public OAuth client), the workspace GitHub connect button now opens a device-code dialog instead of the redirect popup: it calls github/device/start, shows the user_code + a button to github.com/login/ device, polls github/device/poll, and on complete refreshes the GitHub integration + opens the repo picker (same as the redirect callback did). Cloud keeps the popup redirect flow. REVISION: github-device-dialog-v1 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- .../components/blocks/GithubDeviceDialog.tsx | 178 ++++++++++++++++++ .../src/components/blocks/WorkspaceBlock.tsx | 20 +- .../src/lib/api/cloudflare/integrations.ts | 32 ++++ 3 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/blocks/GithubDeviceDialog.tsx diff --git a/frontend/src/components/blocks/GithubDeviceDialog.tsx b/frontend/src/components/blocks/GithubDeviceDialog.tsx new file mode 100644 index 00000000..733b1e05 --- /dev/null +++ b/frontend/src/components/blocks/GithubDeviceDialog.tsx @@ -0,0 +1,178 @@ +// 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"; + +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); + + 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"); + onConnected(); + setTimeout(() => onOpenChange(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, onConnected, onOpenChange, 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/WorkspaceBlock.tsx b/frontend/src/components/blocks/WorkspaceBlock.tsx index 794bd8a1..e117e3c1 100644 --- a/frontend/src/components/blocks/WorkspaceBlock.tsx +++ b/frontend/src/components/blocks/WorkspaceBlock.tsx @@ -76,7 +76,8 @@ 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 { GithubDeviceDialog } from "./GithubDeviceDialog"; // Module-level cache to prevent integration reload storms across component remounts // Tracks dashboardId -> timestamp of last load @@ -196,6 +197,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); @@ -274,6 +276,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) { @@ -1615,6 +1623,16 @@ export function WorkspaceBlock({ id, data, selected }: NodeProps) )} + { + void loadGithubIntegration(); + setGithubPickerOpen(true); + void loadGithubRepos(); + }} + /> GitHub 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); } From 89503842d75a3d9435751fcba9402401b15bd149 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 7 Jul 2026 20:19:09 +0100 Subject: [PATCH 10/21] fix(frontend): send surface token on the desktop auth bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit providers.tsx establishes the desktop user with raw fetch() calls (/auth/dev/session + /me ID-sync) using getAuthHeaders() — but the surface token is only loaded inside apiFetch, so those bootstrap calls carried no X-Orcabot-Surface and 401'd under enforcement. The /me ID-sync then failed, leaving the local user on a client-generated ID (wrong user → empty dashboards). Await ensureSurfaceToken() before the bootstrap fetches so they carry the token. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- frontend/src/app/providers.tsx | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/providers.tsx b/frontend/src/app/providers.tsx index 849dca05..5cd0f30c 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,6 +116,11 @@ function AuthBootstrapper() { const bootstrap = async () => { // Desktop mode: auto-login as local user, no auth needed if (DESKTOP_MODE) { + // Load the surface token first 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 and the local user keeps a + // client-generated ID (wrong user → empty dashboards). + await ensureSurfaceToken(); loginDevMode("Desktop User", "desktop@localhost"); const authHeaders = getAuthHeaders(); try { From 2293c20d6d402f4250277d05dae755ebbaf35d10 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 7 Jul 2026 21:14:59 +0100 Subject: [PATCH 11/21] fix(desktop): allow X-Orcabot-Surface through CORS; robust auth bootstrap The real cause of "Failed to load dashboards": the frontend adds the X-Orcabot-Surface header, but it wasn't in the control plane's CORS Access-Control-Allow-Headers, so the browser preflight blocked every authed request before it reached the server. Add X-Orcabot-Surface to CORS_ALLOWED_HEADERS. Also: providers.tsx now sets the local dev user before awaiting the surface token (bounded) so a slow token fetch can't block login, and reuses userId for the ID-match check. Reverted the temporary diagnostic logs. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- controlplane/src/index.ts | 2 +- frontend/src/app/providers.tsx | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/controlplane/src/index.ts b/controlplane/src/index.ts index c579b7e8..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). diff --git a/frontend/src/app/providers.tsx b/frontend/src/app/providers.tsx index 5cd0f30c..c432b008 100644 --- a/frontend/src/app/providers.tsx +++ b/frontend/src/app/providers.tsx @@ -116,12 +116,16 @@ function AuthBootstrapper() { const bootstrap = async () => { // Desktop mode: auto-login as local user, no auth needed if (DESKTOP_MODE) { - // Load the surface token first 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 and the local user keeps a - // client-generated ID (wrong user → empty dashboards). - await ensureSurfaceToken(); + // 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 From 7959cf914106d44134cc334d621b3e82f7b2c5d8 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 7 Jul 2026 21:28:36 +0100 Subject: [PATCH 12/21] feat(desktop): OS-browser OAuth connect (open_url + connectViaBrowser) + Gmail window.open is a no-op in the Tauri webview, so every popup-based OAuth connect button silently did nothing on desktop. Add an open_url Tauri command (opens the OS default browser) + a connectViaBrowser helper that opens the provider URL there and polls the integration status until connected (replacing the popup + postMessage handshake, which can't work across the OS browser). Wire Gmail as the reference; other providers follow. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- desktop/app/src-tauri/src/commands.rs | 23 ++++++++ desktop/app/src-tauri/src/main.rs | 1 + frontend/src/components/blocks/GmailBlock.tsx | 19 +++++- frontend/src/lib/oauth-connect.ts | 59 +++++++++++++++++++ frontend/src/lib/tauri-bridge.ts | 20 +++++++ 5 files changed, 121 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/oauth-connect.ts diff --git a/desktop/app/src-tauri/src/commands.rs b/desktop/app/src-tauri/src/commands.rs index 1a38962d..fef3d2d4 100644 --- a/desktop/app/src-tauri/src/commands.rs +++ b/desktop/app/src-tauri/src/commands.rs @@ -616,6 +616,29 @@ 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 f564c1ec..e6e0d405 100644 --- a/desktop/app/src-tauri/src/main.rs +++ b/desktop/app/src-tauri/src/main.rs @@ -812,6 +812,7 @@ fn main() { 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/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/lib/oauth-connect.ts b/frontend/src/lib/oauth-connect.ts new file mode 100644 index 00000000..ddaa47ef --- /dev/null +++ b/frontend/src/lib/oauth-connect.ts @@ -0,0 +1,59 @@ +// Copyright 2026 Rob Macrae. All rights reserved. +// SPDX-License-Identifier: LicenseRef-Proprietary +"use client"; + +import { openExternalUrl } from "@/lib/tauri-bridge"; + +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; + + void openExternalUrl(url); + + const start = Date.now(); + let stopped = false; + const timer = setInterval(async () => { + if (stopped) return; + if (Date.now() - start > timeoutMs) { + clearInterval(timer); + return; + } + try { + if (await checkConnected()) { + clearInterval(timer); + if (!stopped) onConnected(); + } + } catch { + /* transient — keep polling */ + } + }, intervalMs); + + return () => { + stopped = true; + clearInterval(timer); + }; +} diff --git a/frontend/src/lib/tauri-bridge.ts b/frontend/src/lib/tauri-bridge.ts index b1e92413..4ec2ab25 100644 --- a/frontend/src/lib/tauri-bridge.ts +++ b/frontend/src/lib/tauri-bridge.ts @@ -139,6 +139,26 @@ 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"); + } +} + /** Open a native folder picker dialog. Returns the selected path or null if cancelled. */ export async function pickFolder(): Promise { if (!DESKTOP_MODE) return null; From c0cc1081febdd018f7a49d57552f2974baaa803a Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 7 Jul 2026 21:32:55 +0100 Subject: [PATCH 13/21] feat(desktop): OS-browser OAuth connect for all popup-based providers Wire connectViaBrowser (open OS browser + poll status) into every provider that used a window.open popup, which is a no-op in the Tauri webview: Google Calendar/Contacts/Sheets/Forms, Drive/Box/OneDrive (WorkspaceBlock), Microsoft Outlook/Outlook-Calendar/Teams, Slack, Discord, and the generic IntegrationsPanel. Each keeps its existing web popup/postMessage path; the desktop branch opens the OS browser and refreshes the block on connect. GitHub (device-flow modal) and Gmail (reference) were done earlier. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- .../src/components/blocks/CalendarBlock.tsx | 19 ++++++- .../src/components/blocks/ContactsBlock.tsx | 19 ++++++- .../src/components/blocks/DiscordBlock.tsx | 21 ++++++- frontend/src/components/blocks/FormsBlock.tsx | 18 +++++- .../components/blocks/IntegrationsPanel.tsx | 22 +++++++- .../src/components/blocks/OutlookBlock.tsx | 24 +++++++- .../blocks/OutlookCalendarBlock.tsx | 17 +++++- .../src/components/blocks/SheetsBlock.tsx | 18 +++++- frontend/src/components/blocks/SlackBlock.tsx | 21 ++++++- frontend/src/components/blocks/TeamsBlock.tsx | 17 +++++- .../src/components/blocks/WorkspaceBlock.tsx | 56 +++++++++++++++++++ 11 files changed, 242 insertions(+), 10 deletions(-) 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/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 e117e3c1..825d843b 100644 --- a/frontend/src/components/blocks/WorkspaceBlock.tsx +++ b/frontend/src/components/blocks/WorkspaceBlock.tsx @@ -77,6 +77,7 @@ import { HelpButton } from "@/components/help/HelpDialog"; import { workspaceDoc } from "@/docs/content/workspace"; import { useAuthStore } from "@/stores/auth-store"; 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 @@ -255,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); @@ -300,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) { @@ -318,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) { From 08d98791425a0652cefb8752a5e8da9a4127d372 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 7 Jul 2026 22:26:20 +0100 Subject: [PATCH 14/21] fix(desktop): pass dev-auth + surface token as query params for OS-browser OAuth The connect endpoints require auth, but a top-level browser navigation (the OAuth connect opened in the OS browser) can't send X-User-ID or X-Orcabot-Surface headers, so it 401'd. connectViaBrowser now appends user_id/user_email/user_name + surface as query params, and the surface gate accepts the token via ?surface= too (authenticateDevMode already reads user_id from the query, as it does for WebSockets). Header path unchanged for fetch/XHR. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- controlplane/src/auth/middleware.ts | 7 ++++++- frontend/src/lib/oauth-connect.ts | 24 ++++++++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/controlplane/src/auth/middleware.ts b/controlplane/src/auth/middleware.ts index b5af0e03..1694884b 100644 --- a/controlplane/src/auth/middleware.ts +++ b/controlplane/src/auth/middleware.ts @@ -80,7 +80,12 @@ export async function authenticate( function devAuthSurfaceTrusted(request: Request, env: Env): boolean { const expected = env.SURFACE_TOKEN; if (!expected) return true; - return request.headers.get('X-Orcabot-Surface') === expected; + // 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 diff --git a/frontend/src/lib/oauth-connect.ts b/frontend/src/lib/oauth-connect.ts index ddaa47ef..78d53563 100644 --- a/frontend/src/lib/oauth-connect.ts +++ b/frontend/src/lib/oauth-connect.ts @@ -2,7 +2,8 @@ // SPDX-License-Identifier: LicenseRef-Proprietary "use client"; -import { openExternalUrl } from "@/lib/tauri-bridge"; +import { openExternalUrl, ensureSurfaceToken } 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 @@ -32,7 +33,26 @@ export function connectViaBrowser(opts: BrowserOAuthConnect): () => void { timeoutMs = 300000, } = opts; - void openExternalUrl(url); + // 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; From e1e59bf581a39a801db7e295236e09408b0ba044 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 7 Jul 2026 22:32:59 +0100 Subject: [PATCH 15/21] fix(desktop): detect OAuth connect on app focus; graceful close-tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit connectViaBrowser now re-checks the integration status immediately when the app regains focus (window focus + visibilitychange), not just on the setInterval — background webviews throttle timers while the OS browser is foreground, so returning to Orcabot now picks up the connection right away instead of only on the next click. Also: the OAuth success page's Close-tab button falls back to a "close it yourself" hint when the browser blocks window.close() (e.g. Safari). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- controlplane/src/integrations/handler.ts | 5 +-- frontend/src/lib/oauth-connect.ts | 40 +++++++++++++++++++----- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/controlplane/src/integrations/handler.ts b/controlplane/src/integrations/handler.ts index eee46d78..292987d4 100644 --- a/controlplane/src/integrations/handler.ts +++ b/controlplane/src/integrations/handler.ts @@ -846,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.

+ +
`, diff --git a/frontend/src/lib/oauth-connect.ts b/frontend/src/lib/oauth-connect.ts index 78d53563..bf086e86 100644 --- a/frontend/src/lib/oauth-connect.ts +++ b/frontend/src/lib/oauth-connect.ts @@ -56,24 +56,48 @@ export function connectViaBrowser(opts: BrowserOAuthConnect): () => void { const start = Date.now(); let stopped = false; - const timer = setInterval(async () => { + let timer: ReturnType | null = null; + + const cleanup = () => { + stopped = true; + if (timer) { + clearInterval(timer); + timer = null; + } + if (typeof window !== "undefined") { + window.removeEventListener("focus", onFocus); + document.removeEventListener("visibilitychange", onVisibility); + } + }; + + const check = async () => { if (stopped) return; if (Date.now() - start > timeoutMs) { - clearInterval(timer); + cleanup(); return; } try { if (await checkConnected()) { - clearInterval(timer); - if (!stopped) onConnected(); + cleanup(); + onConnected(); } } catch { /* transient — keep polling */ } - }, intervalMs); + }; - return () => { - stopped = true; - clearInterval(timer); + // 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); + } + + return cleanup; } From 88f236cd3c3b81443aead97a9a66215d71c36c6a Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 7 Jul 2026 22:38:43 +0100 Subject: [PATCH 16/21] fix(desktop): detect OAuth connect via native Tauri focus event; bake more providers The webview's own focus/visibilitychange events don't fire reliably across an OS app-switch (Safari -> Orcabot), so returning after authorizing didn't pick up the connection until the next click. connectViaBrowser now also subscribes to the native `tauri://focus` event and re-checks the status when the app becomes active again. Also extend the build-time OAuth-credential bake list to Microsoft, Slack, Discord, Twitter, OneDrive, and Box so those providers can be configured the same way as Google/GitHub. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- desktop/scripts/build-desktop-resources.sh | 10 +++++++++- frontend/src/lib/oauth-connect.ts | 13 ++++++++++++- frontend/src/lib/tauri-bridge.ts | 20 ++++++++++++++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) diff --git a/desktop/scripts/build-desktop-resources.sh b/desktop/scripts/build-desktop-resources.sh index 5370cb20..3382cb40 100755 --- a/desktop/scripts/build-desktop-resources.sh +++ b/desktop/scripts/build-desktop-resources.sh @@ -47,7 +47,15 @@ cp "$ROOT_DIR/workerd/config/workerd.desktop.capnp" "$WORKERD_CONFIG_DIR/workerd # 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" -for _oauth_var in GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET GOOGLE_API_KEY GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET; do +for _oauth_var in \ + GOOGLE_CLIENT_ID GOOGLE_CLIENT_SECRET GOOGLE_API_KEY \ + GITHUB_CLIENT_ID GITHUB_CLIENT_SECRET \ + MICROSOFT_CLIENT_ID MICROSOFT_CLIENT_SECRET \ + ONEDRIVE_CLIENT_ID ONEDRIVE_CLIENT_SECRET \ + BOX_CLIENT_ID BOX_CLIENT_SECRET \ + SLACK_CLIENT_ID SLACK_CLIENT_SECRET \ + DISCORD_CLIENT_ID DISCORD_CLIENT_SECRET \ + TWITTER_CLIENT_ID TWITTER_CLIENT_SECRET; do _oauth_val=$(printenv "$_oauth_var" 2>/dev/null) || _oauth_val="" [ -n "$_oauth_val" ] || continue if python3 - "$STAGED_CAPNP" "$_oauth_var" "$_oauth_val" <<'PY' diff --git a/frontend/src/lib/oauth-connect.ts b/frontend/src/lib/oauth-connect.ts index bf086e86..5317dc26 100644 --- a/frontend/src/lib/oauth-connect.ts +++ b/frontend/src/lib/oauth-connect.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: LicenseRef-Proprietary "use client"; -import { openExternalUrl, ensureSurfaceToken } from "@/lib/tauri-bridge"; +import { openExternalUrl, ensureSurfaceToken, onAppFocus } from "@/lib/tauri-bridge"; import { getAuthHeaders } from "@/stores/auth-store"; export interface BrowserOAuthConnect { @@ -57,6 +57,7 @@ export function connectViaBrowser(opts: BrowserOAuthConnect): () => void { const start = Date.now(); let stopped = false; let timer: ReturnType | null = null; + let unlistenTauriFocus: (() => void) | null = null; const cleanup = () => { stopped = true; @@ -64,6 +65,10 @@ export function connectViaBrowser(opts: BrowserOAuthConnect): () => void { clearInterval(timer); timer = null; } + if (unlistenTauriFocus) { + unlistenTauriFocus(); + unlistenTauriFocus = null; + } if (typeof window !== "undefined") { window.removeEventListener("focus", onFocus); document.removeEventListener("visibilitychange", onVisibility); @@ -98,6 +103,12 @@ export function connectViaBrowser(opts: BrowserOAuthConnect): () => void { 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 4ec2ab25..33044f28 100644 --- a/frontend/src/lib/tauri-bridge.ts +++ b/frontend/src/lib/tauri-bridge.ts @@ -159,6 +159,26 @@ export async function openExternalUrl(url: string): Promise { } } +/** + * 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; From 09d146f60e8f6fd01e3038fa1a40a51f958337a5 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 7 Jul 2026 23:26:20 +0100 Subject: [PATCH 17/21] feat(desktop): hide Slack, Discord, and X from the canvas palette They rely on confidential OAuth client secrets that the desktop build doesn't ship, so drop them from the messaging integrations palette when DESKTOP_MODE (cloud keeps them). Removes dead-end connect buttons on desktop. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- frontend/src/app/(app)/dashboards/[id]/page.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/(app)/dashboards/[id]/page.tsx b/frontend/src/app/(app)/dashboards/[id]/page.tsx index cc24d668..90ba4169 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,12 @@ 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, so hide them there (cloud keeps them). +const messagingTools: BlockTool[] = messagingToolsAll.filter( + (t) => !(DESKTOP_MODE && (t.type === "slack" || t.type === "discord" || t.type === "twitter")) +); + const terminalTools: BlockTool[] = [ { type: "terminal", From a943a8718e75491bfffa194a9d1d3d3f9ecfe1a1 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 7 Jul 2026 23:40:27 +0100 Subject: [PATCH 18/21] feat(oauth): Microsoft PKCE public-client flow (Outlook/Calendar/Teams/OneDrive) Same treatment as Google, plus: Microsoft public clients send no secret, so the connect/callback drop client_secret and relax the "not configured" guard on the public-client path (only client_id required). Cloud keeps the confidential Web-app flow. Token-refresh path still to convert. REVISION: oauth-ms-pkce-v1 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- controlplane/src/integrations/handler.ts | 68 +++++++++++++++++------- 1 file changed, 48 insertions(+), 20 deletions(-) diff --git a/controlplane/src/integrations/handler.ts b/controlplane/src/integrations/handler.ts index 292987d4..c28d7302 100644 --- a/controlplane/src/integrations/handler.ts +++ b/controlplane/src/integrations/handler.ts @@ -5004,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.'); } @@ -5012,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`; @@ -5028,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); } @@ -5036,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.'); } @@ -5057,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', { @@ -11586,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.'); } @@ -11594,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`; @@ -11610,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); } @@ -11620,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.'); } @@ -11646,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', { @@ -11771,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.'); } @@ -11779,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`; @@ -11795,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); } @@ -11805,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.'); } @@ -11827,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', { @@ -12516,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.'); } @@ -12524,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`; @@ -12540,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); } @@ -12550,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.'); } @@ -12576,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', { From ac1479ee05bdcb3a2e63d9ef4e6ed8d4a67c3cbf Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Tue, 7 Jul 2026 23:56:35 +0100 Subject: [PATCH 19/21] feat(oauth): Microsoft token refresh works on the public-client path The Microsoft refreshers (refreshOnedriveAccessToken, refreshOutlookAccessToken, and the gateway token-refresh.ts branch that covers teams/outlook/onedrive/ outlook_calendar) sent client_secret unconditionally and required it, so a public-client integration would break at the ~1h token expiry. Relax the guard and omit client_secret when OAUTH_PUBLIC_CLIENT is set, so PKCE-issued tokens refresh with no secret. Cloud (confidential) path unchanged. REVISION: oauth-ms-pkce-v1 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- controlplane/src/integration-policies/token-refresh.ts | 6 ++++-- controlplane/src/integrations/handler.ts | 8 ++++---- 2 files changed, 8 insertions(+), 6 deletions(-) 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 c28d7302..c07e67f9 100644 --- a/controlplane/src/integrations/handler.ts +++ b/controlplane/src/integrations/handler.ts @@ -636,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.'); } @@ -651,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(' ')); @@ -11965,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.'); } @@ -11992,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(' ')); From b00d01ec11adba8324903cab6f440b7550ff45b0 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Wed, 8 Jul 2026 00:03:08 +0100 Subject: [PATCH 20/21] feat(desktop): hide WhatsApp on desktop + hide empty messaging section WhatsApp needs the Baileys bridge (a separate Fly service) the desktop build doesn't bundle, so hide it on desktop alongside Slack/Discord/X. With all messaging providers hidden on desktop, also hide the Messaging toolbar section when it's empty so it doesn't render a dangling collapse toggle. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- frontend/src/app/(app)/dashboards/[id]/page.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/(app)/dashboards/[id]/page.tsx b/frontend/src/app/(app)/dashboards/[id]/page.tsx index 90ba4169..d2b0512b 100644 --- a/frontend/src/app/(app)/dashboards/[id]/page.tsx +++ b/frontend/src/app/(app)/dashboards/[id]/page.tsx @@ -198,9 +198,10 @@ const messagingToolsAll: BlockTool[] = [ ]; // Slack, Discord, and X use confidential OAuth client secrets we don't ship in -// the desktop build, so hide them there (cloud keeps them). +// 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 && (t.type === "slack" || t.type === "discord" || t.type === "twitter")) + (t) => !(DESKTOP_MODE && ["slack", "discord", "twitter", "whatsapp"].includes(t.type)) ); const terminalTools: BlockTool[] = [ @@ -3917,7 +3918,8 @@ export default function DashboardPage() { ))} - {/* Messaging integrations section */} + {/* Messaging integrations section (hidden when empty, e.g. desktop) */} + {messagingTools.length > 0 && (
+ )} {/* Connect mode */}
From 349186cb89f87fbf0431b58cc66fe9304774972c Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Wed, 8 Jul 2026 00:38:03 +0100 Subject: [PATCH 21/21] =?UTF-8?q?fix(desktop):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20bake=20only=20non-confidential=20creds;=20device=20?= =?UTF-8?q?dialog;=20hide=20Box?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P1a: restrict the build-time bake list to non-confidential values only — public client IDs + GOOGLE_CLIENT_SECRET (Google's non-confidential desktop secret) + GOOGLE_API_KEY. GitHub (device flow) and Microsoft/OneDrive (PKCE) need no secret; confidential-secret providers (Slack/Discord/X/Box) are never baked. P2: GithubDeviceDialog no longer restarts the device flow on every parent re-render (onConnected/onOpenChange moved to refs so `begin` is stable), and the "open github.com/login/device" button uses openExternalUrl instead of window.open (a no-op in the Tauri webview). Box: hidden on desktop (confidential secret, can't go secretless like MS). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ --- desktop/scripts/build-desktop-resources.sh | 18 +++++++++++------- .../components/blocks/GithubDeviceDialog.tsx | 15 +++++++++++---- .../src/components/blocks/WorkspaceBlock.tsx | 5 +++-- 3 files changed, 25 insertions(+), 13 deletions(-) diff --git a/desktop/scripts/build-desktop-resources.sh b/desktop/scripts/build-desktop-resources.sh index 3382cb40..b023f3f1 100755 --- a/desktop/scripts/build-desktop-resources.sh +++ b/desktop/scripts/build-desktop-resources.sh @@ -47,15 +47,19 @@ cp "$ROOT_DIR/workerd/config/workerd.desktop.capnp" "$WORKERD_CONFIG_DIR/workerd # 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 GITHUB_CLIENT_SECRET \ - MICROSOFT_CLIENT_ID MICROSOFT_CLIENT_SECRET \ - ONEDRIVE_CLIENT_ID ONEDRIVE_CLIENT_SECRET \ - BOX_CLIENT_ID BOX_CLIENT_SECRET \ - SLACK_CLIENT_ID SLACK_CLIENT_SECRET \ - DISCORD_CLIENT_ID DISCORD_CLIENT_SECRET \ - TWITTER_CLIENT_ID TWITTER_CLIENT_SECRET; do + 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' diff --git a/frontend/src/components/blocks/GithubDeviceDialog.tsx b/frontend/src/components/blocks/GithubDeviceDialog.tsx index 733b1e05..6cc37358 100644 --- a/frontend/src/components/blocks/GithubDeviceDialog.tsx +++ b/frontend/src/components/blocks/GithubDeviceDialog.tsx @@ -14,6 +14,7 @@ import { } 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; @@ -38,6 +39,12 @@ export function GithubDeviceDialog({ open, onOpenChange, dashboardId, onConnecte 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) { @@ -66,8 +73,8 @@ export function GithubDeviceDialog({ open, onOpenChange, dashboardId, onConnecte if (p.status === "complete") { stopPolling(); setPhase("connected"); - onConnected(); - setTimeout(() => onOpenChange(false), 800); + onConnectedRef.current(); + setTimeout(() => onOpenChangeRef.current(false), 800); } else if (p.status === "error") { stopPolling(); setError( @@ -88,7 +95,7 @@ export function GithubDeviceDialog({ open, onOpenChange, dashboardId, onConnecte setError(e instanceof Error ? e.message : "Couldn't start GitHub sign-in."); setPhase("error"); } - }, [dashboardId, onConnected, onOpenChange, stopPolling]); + }, [dashboardId, stopPolling]); // Start when opened; always clean up the poller when closed/unmounted. React.useEffect(() => { @@ -143,7 +150,7 @@ export function GithubDeviceDialog({ open, onOpenChange, dashboardId, onConnecte diff --git a/frontend/src/components/blocks/WorkspaceBlock.tsx b/frontend/src/components/blocks/WorkspaceBlock.tsx index 825d843b..0619f97c 100644 --- a/frontend/src/components/blocks/WorkspaceBlock.tsx +++ b/frontend/src/components/blocks/WorkspaceBlock.tsx @@ -1531,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 ? (