Skip to content

Feat/desktop oauth pkce - #250

Merged
robdmac merged 21 commits into
mainfrom
feat/desktop-oauth-pkce
Jul 8, 2026
Merged

robdmac merged 21 commits into
mainfrom
feat/desktop-oauth-pkce

Conversation

@robdmac

@robdmac robdmac commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

No description provided.

robdmac and others added 21 commits July 6, 2026 21:42
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) <[email protected]>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…ld time

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
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) <[email protected]>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
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) <[email protected]>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
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) <[email protected]>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
… boundary

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
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) <[email protected]>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…rebuild

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
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) <[email protected]>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
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) <[email protected]>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…trap

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…) + 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) <[email protected]>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
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) <[email protected]>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…owser 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) <[email protected]>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
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) <[email protected]>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
… 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) <[email protected]>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
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) <[email protected]>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…s/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) <[email protected]>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
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) <[email protected]>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
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) <[email protected]>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
…ce dialog; hide Box

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) <[email protected]>
Claude-Session: https://claude.ai/code/session_014t8Ukp4NPWtqJ55X261wMZ
@robdmac
robdmac merged commit 25dcbf9 into main Jul 8, 2026
1 check passed
@robdmac
robdmac deleted the feat/desktop-oauth-pkce branch July 8, 2026 06:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant