diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 1272ec3a..91ec2f78 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -1517,9 +1517,10 @@ const extractCookieHeader = (response) => { .join('; '); }; -const loginRemoteAndIssueClientToken = async ({ url, password, trustDevice }) => { +const loginRemoteAndIssueClientToken = async ({ url, password, trustDevice, requestHeaders }) => { const baseUrl = normalizeHostUrl(String(url || '')); const candidatePassword = typeof password === 'string' ? password : ''; + const safeRequestHeaders = sanitizeRuntimeRequestHeaders(requestHeaders || {}); if (!baseUrl) throw new Error('Invalid URL'); if (!candidatePassword) throw new Error('Password is required'); @@ -1527,6 +1528,7 @@ const loginRemoteAndIssueClientToken = async ({ url, password, trustDevice }) => method: 'POST', signal: AbortSignal.timeout(10_000), headers: { + ...safeRequestHeaders, Accept: 'application/json', 'Content-Type': 'application/json', }, @@ -1559,6 +1561,7 @@ const loginRemoteAndIssueClientToken = async ({ url, password, trustDevice }) => method: 'POST', signal: AbortSignal.timeout(10_000), headers: { + ...safeRequestHeaders, Accept: 'application/json', 'Content-Type': 'application/json', Cookie: cookie, @@ -3506,6 +3509,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => { url: args.url, password: args.password, trustDevice: args.trustDevice === true, + requestHeaders: args.requestHeaders || {}, }); case 'desktop_set_window_theme': { diff --git a/packages/electron/ssh-manager.mjs b/packages/electron/ssh-manager.mjs index 38c14bae..7075b552 100644 --- a/packages/electron/ssh-manager.mjs +++ b/packages/electron/ssh-manager.mjs @@ -700,19 +700,84 @@ export class ElectronSshManager { } async updateHostUrl(instanceId, label, localUrl) { + return this.updateHostRuntime(instanceId, label, localUrl, ''); + } + + async updateHostRuntime(instanceId, label, localUrl, clientToken = '') { const root = readJsonRoot(this.settingsFilePath); const hosts = Array.isArray(root.desktopHosts) ? root.desktopHosts : []; const existing = hosts.find((entry) => entry?.id === instanceId); + const token = typeof clientToken === 'string' ? clientToken.trim() : ''; if (existing) { existing.label = label; existing.url = localUrl; + existing.apiUrl = localUrl; + if (token) existing.clientToken = token; } else { - hosts.push({ id: instanceId, label, url: localUrl }); + hosts.push({ id: instanceId, label, url: localUrl, apiUrl: localUrl, ...(token ? { clientToken: token } : {}) }); } root.desktopHosts = hosts; await writeJsonRoot(this.settingsFilePath, root); } + async issueClientToken(localUrl, openchamberPassword) { + const password = typeof openchamberPassword === 'string' ? openchamberPassword.trim() : ''; + if (!password) return ''; + + const loginResponse = await fetch(new URL('/auth/session', `${localUrl}/`).toString(), { + method: 'POST', + signal: AbortSignal.timeout(10_000), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + password, + trustDevice: true, + issueClientToken: true, + clientLabel: 'OpenChamber Desktop SSH', + }), + }); + if (!loginResponse.ok) { + throw new Error(`Configured OpenChamber UI password was rejected by forwarded server (status ${loginResponse.status})`); + } + + const payload = await loginResponse.json().catch(() => null); + const token = typeof payload?.clientToken === 'string' ? payload.clientToken.trim() : ''; + if (token) return token; + + const cookie = this.extractCookieHeader(loginResponse); + if (!cookie) return ''; + + const tokenResponse = await fetch(new URL('/api/client-auth/clients', `${localUrl}/`).toString(), { + method: 'POST', + signal: AbortSignal.timeout(10_000), + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + Cookie: cookie, + }, + body: JSON.stringify({ label: 'OpenChamber Desktop SSH' }), + }); + if (!tokenResponse.ok) return ''; + const tokenPayload = await tokenResponse.json().catch(() => null); + return typeof tokenPayload?.token === 'string' ? tokenPayload.token.trim() : ''; + } + + extractCookieHeader(response) { + const getSetCookie = typeof response.headers?.getSetCookie === 'function' + ? response.headers.getSetCookie.bind(response.headers) + : null; + const cookies = getSetCookie ? getSetCookie() : []; + const rawCookies = cookies.length > 0 + ? cookies + : String(response.headers?.get?.('set-cookie') || '').split(/,(?=\s*[^;,=]+=[^;,]+)/); + return rawCookies + .map((cookie) => String(cookie || '').split(';')[0].trim()) + .filter(Boolean) + .join('; '); + } + async persistLocalPort(instanceId, localPort) { const root = readJsonRoot(this.settingsFilePath); const instances = Array.isArray(root.desktopSshInstances) ? root.desktopSshInstances : []; @@ -1090,7 +1155,8 @@ export class ElectronSshManager { const localUrl = `http://127.0.0.1:${localPort}`; const label = instance.nickname?.trim() || parsed.destination || id; - await this.updateHostUrl(id, label, localUrl); + const clientToken = await this.issueClientToken(localUrl, this.configuredOpenChamberPassword(instance)); + await this.updateHostRuntime(id, label, localUrl, clientToken); if (instance.localForward?.preferredLocalPort !== localPort) { await this.persistLocalPort(id, localPort); } diff --git a/packages/electron/ssh-manager.test.mjs b/packages/electron/ssh-manager.test.mjs new file mode 100644 index 00000000..f31c90f8 --- /dev/null +++ b/packages/electron/ssh-manager.test.mjs @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import fs from 'node:fs'; +import fsp from 'node:fs/promises'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; + +import { ElectronSshManager } from './ssh-manager.mjs'; + +const servers = []; +const tempDirs = []; + +const listen = async (server) => { + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + servers.push(server); + const address = server.address(); + if (!address || typeof address === 'string') throw new Error('Expected TCP server address'); + return `http://127.0.0.1:${address.port}`; +}; + +const readBody = async (req) => { + let body = ''; + for await (const chunk of req) body += chunk.toString(); + return body; +}; + +afterEach(async () => { + while (servers.length > 0) { + const server = servers.pop(); + await new Promise((resolve) => server.close(() => resolve())); + } + while (tempDirs.length > 0) { + await fsp.rm(tempDirs.pop(), { recursive: true, force: true }); + } +}); + +describe('ElectronSshManager', () => { + test('stores a client token for forwarded OpenChamber hosts when UI password is configured', async () => { + let loginPayload = null; + const server = http.createServer(async (req, res) => { + if (req.method === 'POST' && req.url === '/auth/session') { + loginPayload = JSON.parse(await readBody(req)); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ authenticated: true, clientToken: 'ssh-client-token' })); + return; + } + res.writeHead(404).end(); + }); + const localUrl = await listen(server); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-ssh-manager-test-')); + tempDirs.push(tempDir); + const settingsFilePath = path.join(tempDir, 'settings.json'); + const manager = new ElectronSshManager({ + settingsFilePath, + appVersion: '0.0.0-test', + emit: () => undefined, + }); + + const token = await manager.issueClientToken(localUrl, 'ui-secret'); + await manager.updateHostRuntime('ssh-1', 'SSH Host', localUrl, token); + + const settings = JSON.parse(fs.readFileSync(settingsFilePath, 'utf8')); + expect(loginPayload).toMatchObject({ + password: 'ui-secret', + trustDevice: true, + issueClientToken: true, + }); + expect(settings.desktopHosts).toEqual([{ id: 'ssh-1', label: 'SSH Host', url: localUrl, apiUrl: localUrl, clientToken: 'ssh-client-token' }]); + }); +}); diff --git a/packages/ui/src/components/auth/SessionAuthGate.tsx b/packages/ui/src/components/auth/SessionAuthGate.tsx index 9f4e23b1..8dbe460b 100644 --- a/packages/ui/src/components/auth/SessionAuthGate.tsx +++ b/packages/ui/src/components/auth/SessionAuthGate.tsx @@ -12,6 +12,7 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo'; import { Icon } from "@/components/icon/Icon"; import { useI18n } from '@/lib/i18n'; import { runtimeFetch } from '@/lib/runtime-fetch'; +import { getRuntimeExtraHeadersSync } from '@/lib/runtime-auth'; import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch'; import { desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts'; import { @@ -129,20 +130,30 @@ const shouldUseDesktopShellPasswordLogin = (): boolean => { return isDesktopShell() && !isLocalDesktopRuntime(); }; -const issueDesktopClientTokenViaShell = async (password: string, trustDevice: boolean): Promise => { +type DesktopPasswordLoginResult = { + token: string; + status?: number; +}; + +const issueDesktopClientTokenViaShell = async (password: string, trustDevice: boolean): Promise => { if (!isDesktopShell() || typeof window === 'undefined') { - return ''; + return null; } const response = await invokeDesktop('desktop_remote_password_login', { url: getRuntimeApiBaseUrl(), password, trustDevice, + requestHeaders: getRuntimeExtraHeadersSync(), }).catch(() => null); if (!response || typeof response !== 'object') { - return ''; + return null; } const token = (response as { token?: unknown }).token; - return typeof token === 'string' ? token.trim() : ''; + const status = (response as { status?: unknown }).status; + return { + token: typeof token === 'string' ? token.trim() : '', + ...(typeof status === 'number' ? { status } : {}), + }; }; const persistDesktopClientToken = async (apiBaseUrl: string, clientToken: string): Promise => { @@ -180,8 +191,13 @@ const persistDesktopClientToken = async (apiBaseUrl: string, clientToken: string const applyDesktopClientToken = async (clientToken: string): Promise => { if (!clientToken) return; const apiBaseUrl = getRuntimeApiBaseUrl(); + const requestHeaders = getRuntimeExtraHeadersSync(); await persistDesktopClientToken(apiBaseUrl, clientToken); - switchRuntimeEndpoint({ apiBaseUrl, clientToken }); + switchRuntimeEndpoint({ + apiBaseUrl, + clientToken, + requestHeaders: Object.keys(requestHeaders).length > 0 ? requestHeaders : null, + }); }; const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => { @@ -486,15 +502,43 @@ export const SessionAuthGate: React.FC = ({ children }) => setErrorMessage(''); try { + if (shouldUseDesktopShellPasswordLogin()) { + const shellLogin = await issueDesktopClientTokenViaShell(password, trustDevice); + if (shellLogin?.token) { + setPassword(''); + setIsTunnelLocked(false); + await applyDesktopClientToken(shellLogin.token); + setState('authenticated'); + return; + } + if (shellLogin?.status === 401) { + setErrorMessage(t('sessionAuth.error.incorrectPassword')); + setIsTunnelLocked(false); + setState('locked'); + return; + } + if (shellLogin?.status === 429) { + setRetryAfter(undefined); + setIsTunnelLocked(false); + setState('rate-limited'); + return; + } + } + const response = await submitPassword(password, trustDevice); if (response.ok) { const payload = await response.json().catch(() => null) as { clientToken?: unknown } | null; const shouldUseClientToken = shouldIssueDesktopClientToken(); - const clientToken = shouldUseClientToken - ? (typeof payload?.clientToken === 'string' && payload.clientToken.trim() + let clientToken = ''; + if (shouldUseClientToken) { + clientToken = typeof payload?.clientToken === 'string' && payload.clientToken.trim() ? payload.clientToken.trim() - : await issueDesktopClientTokenViaShell(password, trustDevice) || await issueDesktopClientToken()) - : ''; + : ''; + if (!clientToken) { + const shellLogin = await issueDesktopClientTokenViaShell(password, trustDevice); + clientToken = shellLogin?.token || await issueDesktopClientToken(); + } + } setPassword(''); setIsTunnelLocked(false); if (clientToken) { @@ -541,16 +585,28 @@ export const SessionAuthGate: React.FC = ({ children }) => setState('error'); } catch (error) { console.warn('Failed to submit UI password:', error); - const clientToken = shouldUseDesktopShellPasswordLogin() + const shellLogin = shouldUseDesktopShellPasswordLogin() ? await issueDesktopClientTokenViaShell(password, trustDevice) - : ''; - if (clientToken) { + : null; + if (shellLogin?.token) { setPassword(''); setIsTunnelLocked(false); - await applyDesktopClientToken(clientToken); + await applyDesktopClientToken(shellLogin.token); setState('authenticated'); return; } + if (shellLogin?.status === 401) { + setErrorMessage(t('sessionAuth.error.incorrectPassword')); + setIsTunnelLocked(false); + setState('locked'); + return; + } + if (shellLogin?.status === 429) { + setRetryAfter(undefined); + setIsTunnelLocked(false); + setState('rate-limited'); + return; + } setErrorMessage(t('sessionAuth.error.networkRetry')); setIsTunnelLocked(false); setState('error'); diff --git a/packages/ui/src/lib/runtime-auth.test.ts b/packages/ui/src/lib/runtime-auth.test.ts index f5cbb58d..58858dfa 100644 --- a/packages/ui/src/lib/runtime-auth.test.ts +++ b/packages/ui/src/lib/runtime-auth.test.ts @@ -115,4 +115,34 @@ describe('runtime auth headers', () => { clearRuntimeAuthCredentialProvider(); } }); + + test('does not remint URL auth token when setting equivalent empty runtime headers', async () => { + const previousFetch = globalThis.fetch; + let fetchCount = 0; + try { + clearRuntimeUrlAuthToken(); + setRuntimeBearerToken('runtime-token'); + setRuntimeExtraHeaders(null); + globalThis.fetch = (async () => { + fetchCount += 1; + return new Response(JSON.stringify({ token: `url-token-${fetchCount}`, expiresAt: Date.now() + 60_000 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + }) as typeof fetch; + + const firstToken = await refreshRuntimeUrlAuthToken('https://runtime.example'); + setRuntimeExtraHeaders({}); + const secondToken = await refreshRuntimeUrlAuthToken('https://runtime.example'); + + expect(firstToken).toBe('url-token-1'); + expect(secondToken).toBe('url-token-1'); + expect(fetchCount).toBe(1); + } finally { + globalThis.fetch = previousFetch; + clearRuntimeUrlAuthToken(); + setRuntimeExtraHeaders(null); + clearRuntimeAuthCredentialProvider(); + } + }); }); diff --git a/packages/ui/src/lib/runtime-auth.ts b/packages/ui/src/lib/runtime-auth.ts index efd1cd93..6aa181db 100644 --- a/packages/ui/src/lib/runtime-auth.ts +++ b/packages/ui/src/lib/runtime-auth.ts @@ -29,6 +29,12 @@ const sanitizeRuntimeExtraHeaders = (headers: Record | null | un return next; }; +const runtimeExtraHeadersEqual = (left: Record, right: Record): boolean => { + const leftEntries = Object.entries(left); + if (leftEntries.length !== Object.keys(right).length) return false; + return leftEntries.every(([key, value]) => right[key] === value); +}; + const normalizeBearerToken = (token: string | null | undefined): string => { if (typeof token !== 'string') return ''; return token.trim(); @@ -95,7 +101,9 @@ export const setRuntimeBearerToken = (token: string | null | undefined): void => export const setRuntimeExtraHeaders = (headers: Record | null | undefined): void => { // These headers are for runtime HTTP fetches and URL-token minting. Browser-owned // realtime transports (EventSource/WebSocket) cannot attach arbitrary headers. - runtimeExtraHeaders = sanitizeRuntimeExtraHeaders(headers); + const next = sanitizeRuntimeExtraHeaders(headers); + if (runtimeExtraHeadersEqual(runtimeExtraHeaders, next)) return; + runtimeExtraHeaders = next; resetRuntimeAuthGeneration(); }; diff --git a/packages/ui/src/lib/runtime-switch.test.ts b/packages/ui/src/lib/runtime-switch.test.ts new file mode 100644 index 00000000..666b845b --- /dev/null +++ b/packages/ui/src/lib/runtime-switch.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'bun:test'; +import { getRuntimeApiBaseUrl, switchRuntimeEndpoint } from './runtime-switch'; +import { clearRuntimeUrlAuthToken, setRuntimeExtraHeaders } from './runtime-auth'; + +describe('runtime endpoint switching', () => { + test('does not throw when Electron preload globals are read-only', () => { + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const previousFetch = globalThis.fetch; + const runtimeWindow = { + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => true, + }; + + try { + clearRuntimeUrlAuthToken(); + setRuntimeExtraHeaders(null); + globalThis.fetch = (async () => new Response(JSON.stringify({ token: 'url-token', expiresAt: Date.now() + 60_000 }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) as typeof fetch; + Object.defineProperty(runtimeWindow, '__OPENCHAMBER_API_BASE_URL__', { + configurable: true, + value: 'http://127.0.0.1:3000', + writable: false, + }); + Object.defineProperty(runtimeWindow, '__OPENCHAMBER_CLIENT_TOKEN__', { + configurable: true, + value: '', + writable: false, + }); + Object.defineProperty(runtimeWindow, '__OPENCHAMBER_RUNTIME_HEADERS__', { + configurable: true, + value: {}, + writable: false, + }); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: runtimeWindow, + }); + + let thrown: unknown = null; + try { + switchRuntimeEndpoint({ + apiBaseUrl: 'https://remote.example', + clientToken: 'client-token', + requestHeaders: null, + }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeNull(); + expect(getRuntimeApiBaseUrl()).toBe('https://remote.example'); + } finally { + globalThis.fetch = previousFetch; + clearRuntimeUrlAuthToken(); + setRuntimeExtraHeaders(null); + if (previousWindow) { + Object.defineProperty(globalThis, 'window', previousWindow); + } else { + Reflect.deleteProperty(globalThis, 'window'); + } + } + }); +}); diff --git a/packages/ui/src/lib/runtime-switch.ts b/packages/ui/src/lib/runtime-switch.ts index 6cb02dfd..b8cfdada 100644 --- a/packages/ui/src/lib/runtime-switch.ts +++ b/packages/ui/src/lib/runtime-switch.ts @@ -13,6 +13,23 @@ const RUNTIME_ENDPOINT_CHANGED_EVENT = 'openchamber:runtime-endpoint-changed'; let activeApiBaseUrl = ''; let activeRuntimeKey = ''; +const setWindowRuntimeValue = ( + runtimeWindow: typeof window & { + __OPENCHAMBER_API_BASE_URL__?: string; + __OPENCHAMBER_CLIENT_TOKEN__?: string; + __OPENCHAMBER_RUNTIME_HEADERS__?: Record; + }, + key: K, + value: (typeof runtimeWindow)[K], +): void => { + try { + runtimeWindow[key] = value; + } catch { + // Electron preload exposes some initial globals through contextBridge, which + // makes them read-only. Runtime switching must still update in-memory state. + } +}; + const normalizeRuntimeUrlKey = (value: string): string => { try { const url = new URL(value); @@ -81,9 +98,9 @@ export const switchRuntimeEndpoint = (options: { apiBaseUrl: string; clientToken __OPENCHAMBER_CLIENT_TOKEN__?: string; __OPENCHAMBER_RUNTIME_HEADERS__?: Record; }; - runtimeWindow.__OPENCHAMBER_API_BASE_URL__ = apiBaseUrl; - runtimeWindow.__OPENCHAMBER_CLIENT_TOKEN__ = options.clientToken || undefined; - runtimeWindow.__OPENCHAMBER_RUNTIME_HEADERS__ = options.requestHeaders || undefined; + setWindowRuntimeValue(runtimeWindow, '__OPENCHAMBER_API_BASE_URL__', apiBaseUrl); + setWindowRuntimeValue(runtimeWindow, '__OPENCHAMBER_CLIENT_TOKEN__', options.clientToken || undefined); + setWindowRuntimeValue(runtimeWindow, '__OPENCHAMBER_RUNTIME_HEADERS__', options.requestHeaders || undefined); } configureRuntimeUrlResolver({ apiBaseUrl, realtimeBaseUrl: apiBaseUrl }); setRuntimeExtraHeaders(options.requestHeaders || null); diff --git a/packages/web/src/runtimeConfig.ts b/packages/web/src/runtimeConfig.ts index de16d298..7622e6d4 100644 --- a/packages/web/src/runtimeConfig.ts +++ b/packages/web/src/runtimeConfig.ts @@ -1,4 +1,4 @@ -import { refreshLocalRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@openchamber/ui/lib/runtime-auth'; +import { getRuntimeExtraHeadersSync, refreshLocalRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken, setRuntimeBearerToken, setRuntimeExtraHeaders } from '@openchamber/ui/lib/runtime-auth'; import { installRuntimeFetchBridge } from '@openchamber/ui/lib/runtime-fetch'; import { initializeRuntimeEndpoint } from '@openchamber/ui/lib/runtime-switch'; import { configureRuntimeUrlResolver } from '@openchamber/ui/lib/runtime-url'; @@ -44,7 +44,7 @@ export const createConfiguredWebAPIs = () => { setRuntimeBearerToken(clientToken || null); setRuntimeExtraHeaders(window.__OPENCHAMBER_RUNTIME_HEADERS__ || null); void refreshRuntimeUrlAuthToken(apiBaseUrl || undefined).catch(() => {}); - if (localOrigin && !sameOrigin(apiBaseUrl, localOrigin)) { + if (localOrigin && !sameOrigin(apiBaseUrl, localOrigin) && Object.keys(getRuntimeExtraHeadersSync()).length > 0) { void refreshLocalRuntimeUrlAuthToken(localOrigin).catch(() => {}); } installRuntimeFetchBridge();