Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/desktop-oauth-system-browser.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"executor": patch
"@executor-js/desktop": patch
---

Desktop OAuth flows now open in the user's default browser instead of an
Electron child window. The renderer skips the in-page popup, calls
`window.executor.openExternal(authorizationUrl)`, and polls
`/api/oauth/await/:sessionId` for the completed result. Provider sessions
the user is already signed into (Google, GitHub, etc.) are picked up
without re-authenticating inside an isolated Electron cookie jar.

The completion side channel is local-only: `setOAuthCompletionListener`
is a noop hook in `@executor-js/api`, and the in-memory result store +
HTTP polling route are registered by the local server. Stateless
deployments (Cloudflare Workers) carry no footprint and continue to use
the existing `postMessage`/`BroadcastChannel` handoff.

Also hides the stdio MCP install command in the desktop renderer — that
path required the `executor` CLI on PATH, which the desktop app does not
provide. Desktop users see only the HTTP install command, which routes
through the running sidecar.
11 changes: 11 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,17 @@ const registerIpcHandlers = () => {
(): DesktopServerSettings => regeneratePassword(),
);
ipcMain.handle("executor:server:restart", () => restartSidecarAndReload());
ipcMain.handle("executor:shell:open-external", async (_evt, rawUrl: unknown) => {
if (typeof rawUrl !== "string") return;
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: untrusted renderer string, URL ctor throws on malformed input
try {
const parsed = new URL(rawUrl);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return;
await shell.openExternal(parsed.toString());
} catch {
// Reject malformed URLs silently — renderer falls back to popup flow.
}
});
};

const boot = async () => {
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ const api = {
restartServer(): Promise<{ readonly port: number; readonly baseUrl: string }> {
return ipcRenderer.invoke("executor:server:restart");
},
/**
* Open an http(s) URL in the user's default browser. Main-side validates
* the scheme. Used by the system-browser OAuth flow.
*/
openExternal(url: string): Promise<void> {
return ipcRenderer.invoke("executor:shell:open-external", url);
},
} as const;

contextBridge.exposeInMainWorld("executor", api);
Expand Down
65 changes: 65 additions & 0 deletions apps/local/src/oauth-result-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Process-wide in-memory store of completed OAuth popup results, keyed by
* sessionId (the `state` parameter from the auth flow).
*
* Exists for clients that can't use the in-browser BroadcastChannel /
* postMessage handoff — specifically the Electron desktop's renderer
* when the user runs the OAuth flow in their system browser. That
* external browser has no shared origin with the desktop's renderer, so
* the renderer instead polls the local server to learn when the flow
* completed.
*
* The store is one-shot: a successful `consumeOAuthResult` removes the
* entry, so a second poll for the same sessionId returns null. Entries
* also expire after `RESULT_TTL_MS` to prevent abandoned flows from
* keeping memory pinned.
*/

import type { OAuthPopupResult } from "@executor-js/sdk";

type AnyResult = OAuthPopupResult<unknown>;

interface StoredResult {
readonly result: AnyResult;
readonly expiresAt: number;
}

const RESULT_TTL_MS = 10 * 60 * 1000; // 10 minutes — long enough for slow MFA prompts

const store = new Map<string, StoredResult>();

const cleanupExpired = (now: number) => {
for (const [sessionId, entry] of store) {
if (entry.expiresAt < now) store.delete(sessionId);
}
};

/**
* Publish a completed OAuth result. Called from `runOAuthCallback` after
* the per-plugin `complete` Effect resolves (success or failure).
*/
export const publishOAuthResult = (result: AnyResult): void => {
const sessionId = result.sessionId;
if (!sessionId) return;
const now = Date.now();
cleanupExpired(now);
store.set(sessionId, { result, expiresAt: now + RESULT_TTL_MS });
};

/**
* Read and remove a result. Returns null if the sessionId has no entry
* (the OAuth flow is still in progress, or the user abandoned it).
*/
export const consumeOAuthResult = (sessionId: string): AnyResult | null => {
const now = Date.now();
cleanupExpired(now);
const entry = store.get(sessionId);
if (!entry) return null;
store.delete(sessionId);
return entry.result;
};

/** Test-only — clears the entire store between tests. */
export const __resetOAuthResultStoreForTests = (): void => {
store.clear();
};
17 changes: 17 additions & 0 deletions apps/local/src/serve-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,20 @@ export const hasFileExtension = (pathname: string): boolean => {
const lastSegment = pathname.split("/").at(-1) ?? "";
return lastSegment.includes(".");
};

/**
* OAuth provider callbacks land here from the user's external browser,
* which has no way to send our Basic auth header. The `state` parameter
* is the cryptographic gate — each in-flight session is server-issued
* and validated by the shared `completeOAuth` before any work happens.
* Bypassing Basic auth on these paths is safe.
*
* Matches:
* - `/api/oauth/callback` — the shared OAuth API mount point.
* - `/api/oauth/await/<sessionId>` — polled by the Electron renderer
* when the user runs the flow in their system browser. The sessionId
* is the cryptographic flow id; results are one-shot, so a leaked
* poll without a matching active flow returns null.
*/
export const isUnauthenticatedOAuthCallbackPath = (pathname: string): boolean =>
/^\/api\/oauth\/(callback|await)(\/|$)/.test(pathname);
33 changes: 30 additions & 3 deletions apps/local/src/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@

import { resolve, join } from "node:path";
import { readdirSync } from "node:fs";
import { setOAuthCompletionListener } from "@executor-js/api";
import { consumeOAuthResult, publishOAuthResult } from "./oauth-result-store";
import { startIntegrationsRefresh } from "./server/integrations";
import { getServerHandlers } from "./server/main";
import {
DEFAULT_ALLOWED_HOSTS,
hasFileExtension,
isLoopbackBindHost,
isUnauthenticatedOAuthCallbackPath,
makeIsAllowedHost,
makeIsAuthorized,
normalizeCredential,
Expand Down Expand Up @@ -114,6 +117,13 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server

const handlers = opts.handlers ?? (await getServerHandlers());

// Mirror every OAuth callback completion into the local in-memory result
// store. The Electron desktop renderer polls /api/oauth/await/:sessionId
// for these when the user runs the flow in their system browser (no
// shared origin → no postMessage). Cloud doesn't register a listener;
// its same-origin web SPA receives results via postMessage directly.
setOAuthCompletionListener((result) => publishOAuthResult(result));

// Build static routes from either embedded assets or disk
let staticRoutes: Record<string, StaticHandler>;
let serveIndex: StaticHandler;
Expand All @@ -140,19 +150,35 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server
return new Response("Forbidden", { status: 403 });
}

if (requiresAuth && !isAuthorized(req)) {
const url = new URL(req.url);

// OAuth provider callbacks are hit by the user's external browser
// and can't carry our Basic auth header. The OAuth `state`
// parameter is the security gate — see isUnauthenticatedOAuthCallbackPath.
const skipAuth = isUnauthenticatedOAuthCallbackPath(url.pathname);

if (requiresAuth && !skipAuth && !isAuthorized(req)) {
return new Response("Unauthorized", {
status: 401,
headers: { "www-authenticate": 'Bearer realm="executor", Basic realm="executor"' },
});
}

const url = new URL(req.url);

if (url.pathname.startsWith("/mcp")) {
return handlers.mcp.handleRequest(req);
}

// OAuth result polling — local-only, served outside the typed API
// because cloud (Cloudflare Workers, stateless) can't back the
// in-memory store. See setOAuthCompletionListener above.
const awaitMatch = /^\/api\/oauth\/await\/([^/?#]+)$/.exec(url.pathname);
if (awaitMatch && req.method === "GET") {
const result = consumeOAuthResult(awaitMatch[1]);
return new Response(JSON.stringify(result), {
headers: { "content-type": "application/json" },
});
}

if (url.pathname.startsWith("/api/") || url.pathname === "/api") {
url.pathname = url.pathname.slice("/api".length) || "/";
return handlers.api.handler(new Request(url, req));
Expand All @@ -177,6 +203,7 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server
return {
port: server.port!,
async stop() {
setOAuthCompletionListener(null);
server.stop(true);
await handlers.mcp.close();
await handlers.api.dispose();
Expand Down
2 changes: 2 additions & 0 deletions packages/core/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ export {
isOAuthPopupResult,
popupDocument,
runOAuthCallback,
setOAuthCompletionListener,
type OAuthCallbackUrlParams,
type OAuthCompletionListener,
type OAuthPopupResult,
type RunOAuthCallbackInput,
} from "./oauth-popup";
Expand Down
26 changes: 26 additions & 0 deletions packages/core/api/src/oauth-popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,29 @@ import { OAUTH_POPUP_MESSAGE_TYPE, type OAuthPopupResult } from "@executor-js/sd
export { OAUTH_POPUP_MESSAGE_TYPE, isOAuthPopupResult } from "@executor-js/sdk";
export type { OAuthPopupResult } from "@executor-js/sdk";

// ---------------------------------------------------------------------------
// Completion listener — optional process-wide hook called every time an
// OAuth flow finishes (success or failure). Lets hosts that can't use the
// in-browser postMessage/BroadcastChannel handoff observe results another
// way (e.g. the local server registers an in-memory registry so the
// Electron renderer can poll over HTTP when the user signed in via the
// system browser).
//
// Default: no listener. Cloud hosts (Cloudflare Workers — stateless,
// multi-isolate) intentionally don't register one; an in-memory side
// channel can't bridge isolates and the same-origin web SPA already
// receives results via postMessage, so the listener is a no-op there.
// ---------------------------------------------------------------------------

export type OAuthCompletionListener = (result: OAuthPopupResult<unknown>) => void;

// TODO: replace with plugin notify framework
let completionListener: OAuthCompletionListener | null = null;

export const setOAuthCompletionListener = (listener: OAuthCompletionListener | null): void => {
completionListener = listener;
};

// ---------------------------------------------------------------------------
// HTML generation
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -154,5 +177,8 @@ export const runOAuthCallback = <TAuth, E, R>(
...(details && details !== short ? { errorDetails: details } : {}),
});
}),
Effect.tap((result) =>
Effect.sync(() => completionListener?.(result as OAuthPopupResult<unknown>)),
),
Effect.map((result) => popupDocument(result, input.channelName)),
);
82 changes: 82 additions & 0 deletions packages/react/src/api/oauth-popup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,85 @@ export const openOAuthPopup = <TAuth>(input: OpenOAuthPopupInput<TAuth>): (() =>
closePopup(popup);
};
};

// ---------------------------------------------------------------------------
// System-browser flow — used when a desktop host (Electron) opens the auth
// URL in the user's real browser. There's no shared origin, so the
// renderer polls `/api/oauth/await/:sessionId` for the result. The local
// server publishes there via `setOAuthCompletionListener` (see
// apps/local/src/serve.ts).
// ---------------------------------------------------------------------------

export type OpenOAuthSystemBrowserInput<TAuth> = {
readonly url: string;
readonly sessionId: string;
readonly openExternal: (url: string) => Promise<void>;
readonly onResult: (data: OAuthPopupResult<TAuth>) => void;
/** Called once if the external open itself fails (URL rejected, IPC error). */
readonly onOpenFailed?: (cause: unknown) => void;
/** Poll cadence. Default 1000ms. */
readonly pollMs?: number;
/** Stop polling after this many ms with no result. Default 10 minutes. */
readonly timeoutMs?: number;
readonly onTimeout?: () => void;
};

const OAUTH_AWAIT_DEFAULT_POLL_MS = 1000;
const OAUTH_AWAIT_DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;

export const openOAuthSystemBrowser = <TAuth>(
input: OpenOAuthSystemBrowserInput<TAuth>,
): (() => void) => {
let settled = false;
let pollHandle: ReturnType<typeof setInterval> | null = null;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
const controller = new AbortController();

const settle = () => {
if (settled) return;
settled = true;
if (pollHandle !== null) clearInterval(pollHandle);
if (timeoutHandle !== null) clearTimeout(timeoutHandle);
controller.abort();
};

const poll = async () => {
if (settled) return;
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: fetch can reject for transient network errors during polling
try {
const response = await fetch(`/api/oauth/await/${encodeURIComponent(input.sessionId)}`, {
signal: controller.signal,
cache: "no-store",
});
if (!response.ok) return;
const body = (await response.json()) as unknown;
if (body === null || settled) return;
if (!isOAuthPopupResult<TAuth>(body)) return;
settle();
input.onResult(body);
} catch {
// Transient — next tick will retry. AbortError after settle is also caught here.
}
};

void (async () => {
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: openExternal is host-provided IPC, no Effect runtime in this browser-only helper
try {
await input.openExternal(input.url);
} catch (cause: unknown) {
if (settled) return;
settle();
input.onOpenFailed?.(cause);
}
})();

pollHandle = setInterval(() => void poll(), input.pollMs ?? OAUTH_AWAIT_DEFAULT_POLL_MS);
void poll();
timeoutHandle = setTimeout(() => {
if (settled) return;
settle();
input.onTimeout?.();
}, input.timeoutMs ?? OAUTH_AWAIT_DEFAULT_TIMEOUT_MS);

return () => settle();
};
6 changes: 5 additions & 1 deletion packages/react/src/components/mcp-install-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,11 @@ export const buildMcpInstallCommand = (input: {
};

export function McpInstallCard(props: { className?: string }) {
const showStdio = isLocal;
// Desktop hosts ship Electron without putting an `executor` binary on
// PATH, and the bundled sidecar is locked to the running app. Force the
// HTTP path there — it routes through the running sidecar with the
// Basic auth header injected by the renderer.
const showStdio = isLocal && readDesktopBridge() === null;
const [mode, setMode] = useState<TransportMode>(showStdio ? "stdio" : "http");
const [origin, setOrigin] = useState<string | null>(null);
const [desktop, setDesktop] = useState<{
Expand Down
Loading
Loading