From 08207787632d0c0df6ef121be17c2e0fb126c464 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 5 Jul 2026 09:50:53 +0300 Subject: [PATCH] fix(desktop): recognize the LAN-bound local server when tagging client tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client-create gate added in 1.13.9 rejects client tokens without the desktop-local kind, but the kind was only attached when the runtime origin exactly matched the injected local origin — an empty (same-origin) api base, loopback aliases, and the embedded server addressed via a LAN interface (0.0.0.0 binds) all minted untagged tokens, which then hit 403 and surfaced as "Local — Auth required" plus the unreachable-server screen. The renderer now treats same-origin and loopback targets as local, the Electron main additionally matches any of the machine's own interface addresses on the local server's port, and a deduped kind-tagged mint migrates away legacy same-label tokens that predate client kinds. The client-create gate itself is unchanged. --- packages/electron/main.mjs | 30 ++++++++++++++++++- .../src/components/auth/SessionAuthGate.tsx | 23 ++++++++++++-- .../server/lib/client-auth/remote-clients.js | 8 +++++ 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index ca188e7e..3e97f541 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -537,9 +537,37 @@ const readDesktopLocalClientToken = () => { return sanitizeClientTokenForStorage(readSettingsRoot().desktopLocalClientToken) || ''; }; +const isMachineLocalHostname = (hostname) => { + const clean = String(hostname || '').replace(/^\[|\]$/g, ''); + if (!clean) return false; + if (clean === 'localhost' || clean === '127.0.0.1' || clean === '::1' || clean === '0.0.0.0' || clean === '::') { + return true; + } + try { + return Object.values(os.networkInterfaces()).some((entries) => + (entries || []).some((entry) => entry?.address === clean)); + } catch { + return false; + } +}; + const isLocalRuntimeUrl = (targetUrl) => { const localUrl = state.sidecarUrl || state.localOrigin || ''; - return Boolean(localUrl && sameOrigin(targetUrl, localUrl)); + if (!localUrl) return false; + if (sameOrigin(targetUrl, localUrl)) return true; + // The embedded server bound to 0.0.0.0 for LAN access is still THIS + // machine's server when addressed via any of its own interfaces on the same + // port — the minted client token must carry the desktop-local kind, or the + // server's client-create gate rejects it (the "Local — Auth required" + + // unreachable-screen regression). + try { + const target = new URL(targetUrl); + const local = new URL(localUrl); + const portOf = (url) => url.port || (url.protocol === 'https:' ? '443' : '80'); + return portOf(target) === portOf(local) && isMachineLocalHostname(target.hostname); + } catch { + return false; + } }; const readDesktopHostsConfig = () => { diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index 8dbe460b..00c7c336 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -51,11 +51,30 @@ const shouldIssueDesktopClientToken = (): boolean => { return isDesktopShell(); }; +const isLoopbackHostname = (hostname: string): boolean => { + const clean = hostname.replace(/^\[|\]$/g, ''); + return clean === 'localhost' || clean === '127.0.0.1' || clean === '::1'; +}; + const isLocalDesktopRuntime = (): boolean => { if (!isDesktopShell()) return false; - const apiBaseUrl = getRuntimeApiBaseUrl(); const localOrigin = readLocalOrigin(); - return Boolean(localOrigin && sameOrigin(localOrigin, apiBaseUrl)); + if (!localOrigin) return false; + // An empty api base means same-origin requests against the page itself — + // which on desktop IS the embedded local server. Requiring an exact origin + // match here used to leave local client tokens untagged (no desktop-local + // clientKind), and the server's client-create gate then 403'd them. + const apiBaseUrl = getRuntimeApiBaseUrl(); + const effectiveTarget = apiBaseUrl || (typeof window !== 'undefined' ? window.location.origin : ''); + if (sameOrigin(localOrigin, effectiveTarget)) return true; + // Loopback aliases (localhost vs 127.0.0.1) still address this machine's + // own server. + try { + const normalized = normalizeHostUrl(effectiveTarget); + return Boolean(normalized && isLoopbackHostname(new URL(normalized).hostname)); + } catch { + return false; + } }; const desktopClientAuthMetadata = (): { clientKind?: string; dedupeKey?: string } => { diff --git a/packages/web/server/lib/client-auth/remote-clients.js b/packages/web/server/lib/client-auth/remote-clients.js index 7b87deb1..e16f391b 100644 --- a/packages/web/server/lib/client-auth/remote-clients.js +++ b/packages/web/server/lib/client-auth/remote-clients.js @@ -135,6 +135,14 @@ export const createRemoteClientAuthRuntime = ({ fsPromises, path, crypto, storeP }; if (normalizedDedupeKey) { store.clients = store.clients.filter((entry) => entry.dedupeKey !== normalizedDedupeKey); + // Migrate pre-clientKind desktop tokens: a deduped, kind-tagged mint + // supersedes legacy records with the same label that carry neither a + // kind nor a dedupe key — those tokens can no longer pass the + // desktop-local client-create gate and would otherwise linger forever. + if (client.clientKind) { + store.clients = store.clients.filter((entry) => + !(entry.label === client.label && !entry.clientKind && !entry.dedupeKey)); + } } store.clients.push(client); await writeStore(store);