diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 3ec0444458..c5f18fa1f2 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -18,6 +18,7 @@ mod fake_camera; mod file_logging; mod gmessages_scanner; mod imessage_scanner; +mod loopback_oauth; #[cfg(target_os = "macos")] mod mascot_native_window; mod mcp_commands; @@ -3224,7 +3225,9 @@ pub fn run() { companion_commands::unregister_companion_hotkey, companion_commands::companion_activate, mcp_commands::mcp_resolve_binary_path, - mcp_commands::mcp_open_client_config + mcp_commands::mcp_open_client_config, + loopback_oauth::start_loopback_oauth_listener, + loopback_oauth::stop_loopback_oauth_listener ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/app/src-tauri/src/loopback_oauth.rs b/app/src-tauri/src/loopback_oauth.rs new file mode 100644 index 0000000000..dd3769d7be --- /dev/null +++ b/app/src-tauri/src/loopback_oauth.rs @@ -0,0 +1,304 @@ +//! Loopback HTTP listener for OAuth / magic-link callbacks (RFC 8252). +//! +//! Used as the preferred desktop redirect target ahead of the `openhuman://` +//! deep link: the frontend asks the shell to bind a one-shot HTTP server on a +//! fixed loopback port, hands the resulting URL to the backend as +//! `redirectUri`, and waits for the `loopback-oauth-callback` Tauri event. +//! +//! Lifecycle is spawn-on-demand: each call to +//! [`start_loopback_oauth_listener`] supersedes any previously-running +//! listener, binds `127.0.0.1:`, accepts connections until either the +//! state-matching `/auth` request arrives or `timeout_secs` elapses, then +//! shuts the listener down. If bind fails (port already in use), the command +//! returns an error and the caller falls back to the deep-link path. +//! +//! Only the `/auth` path is honored — favicons and stray requests get a +//! 404 and keep the loop alive. The state nonce is generated in the shell +//! and returned to the caller; the backend must echo it back as `state=` on +//! the redirect so a hostile page on the same loopback origin cannot fake a +//! callback. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Mutex; +use std::time::Duration; + +use rand::RngCore; +use serde::Serialize; +use tauri::Emitter; + +use crate::AppRuntime; +type AppHandle = tauri::AppHandle; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::sync::oneshot; +use tokio::time::timeout; + +const LOOPBACK_CALLBACK_EVENT: &str = "loopback-oauth-callback"; +const READ_BUFFER_BYTES: usize = 8 * 1024; +const PER_CONNECTION_READ_TIMEOUT: Duration = Duration::from_secs(5); + +struct ActiveListener { + id: u64, + tx: oneshot::Sender<()>, +} + +static NEXT_LISTENER_ID: AtomicU64 = AtomicU64::new(1); +static ACTIVE_LISTENER: Mutex> = Mutex::new(None); + +#[derive(Serialize, Clone)] +pub struct StartResult { + /// Full redirect URI the backend should redirect to, e.g. + /// `http://127.0.0.1:53824/auth`. State is appended by the caller. + pub redirect_uri: String, + /// State nonce the backend must echo back as `?state=`. + pub state: String, +} + +#[derive(Serialize, Clone)] +struct CallbackPayload { + /// Full callback URL including query string. Frontend re-uses the existing + /// `handleAuthDeepLink` parser by converting it to an `openhuman://` URL. + url: String, +} + +fn cancel_active_listener() { + if let Ok(mut guard) = ACTIVE_LISTENER.lock() { + if let Some(active) = guard.take() { + let _ = active.tx.send(()); + } + } +} + +fn install_active_listener(id: u64, tx: oneshot::Sender<()>) { + if let Ok(mut guard) = ACTIVE_LISTENER.lock() { + if let Some(old) = guard.replace(ActiveListener { id, tx }) { + let _ = old.tx.send(()); + } + } +} + +/// Only clear the global slot if it still belongs to this listener's id. +/// A superseded listener's exit must NOT wipe out the newer sender installed +/// by the start that cancelled it. +fn clear_active_listener(id: u64) { + if let Ok(mut guard) = ACTIVE_LISTENER.lock() { + if guard.as_ref().map(|active| active.id) == Some(id) { + *guard = None; + } + } +} + +fn random_state_nonce() -> String { + let mut bytes = [0u8; 16]; + rand::rng().fill_bytes(&mut bytes); + hex::encode(bytes) +} + +/// Parse the request target (path + query) out of an HTTP/1.x request head. +fn parse_request_target(head: &str) -> Option<&str> { + let first_line = head.split("\r\n").next()?; + let mut parts = first_line.split_whitespace(); + let method = parts.next()?; + let target = parts.next()?; + if method.eq_ignore_ascii_case("GET") { + Some(target) + } else { + None + } +} + +/// Return the value of `state=` in a query string, if present. +fn extract_state(query: &str) -> Option<&str> { + query + .split('&') + .filter_map(|pair| pair.split_once('=')) + .find(|(k, _)| *k == "state") + .map(|(_, v)| v) +} + +const SUCCESS_BODY: &str = "Signed in\ +\ +

You're signed in.

\ +

You can close this tab and return to OpenHuman.

\ +"; + +fn http_response(status: &str, body: &str) -> Vec { + format!( + "HTTP/1.1 {status}\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {len}\r\nConnection: close\r\nCache-Control: no-store\r\n\r\n{body}", + len = body.len(), + ) + .into_bytes() +} + +#[tauri::command] +pub async fn start_loopback_oauth_listener( + app: AppHandle, + port: u16, + timeout_secs: u64, +) -> Result { + cancel_active_listener(); + + let bind_addr = format!("127.0.0.1:{port}"); + let listener = TcpListener::bind(&bind_addr) + .await + .map_err(|err| format!("bind {bind_addr} failed: {err}"))?; + // Use the listener's actual bound port for the emitted callback URL so + // the frontend rewrite (`^https?://127.0.0.1:\d+/auth`) always matches, + // even if a future change moves to port 0. + let bound_port = listener + .local_addr() + .map(|addr| addr.port()) + .unwrap_or(port); + log::info!("[loopback-oauth] listening on 127.0.0.1:{bound_port}"); + + let state = random_state_nonce(); + let redirect_uri = format!("http://127.0.0.1:{bound_port}/auth"); + + let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); + let listener_id = NEXT_LISTENER_ID.fetch_add(1, Ordering::Relaxed); + install_active_listener(listener_id, cancel_tx); + + let expected_state = state.clone(); + tauri::async_runtime::spawn(async move { + let lifetime = Duration::from_secs(timeout_secs.max(1)); + let run = run_accept_loop(listener, app, expected_state, bound_port, cancel_rx); + match timeout(lifetime, run).await { + Ok(()) => log::info!("[loopback-oauth] listener finished"), + Err(_) => log::warn!( + "[loopback-oauth] listener timed out after {}s", + lifetime.as_secs() + ), + } + clear_active_listener(listener_id); + }); + + Ok(StartResult { + redirect_uri, + state, + }) +} + +#[tauri::command] +pub async fn stop_loopback_oauth_listener() -> Result<(), String> { + cancel_active_listener(); + Ok(()) +} + +async fn run_accept_loop( + listener: TcpListener, + app: AppHandle, + expected_state: String, + bound_port: u16, + mut cancel_rx: oneshot::Receiver<()>, +) { + loop { + tokio::select! { + _ = &mut cancel_rx => { + log::debug!("[loopback-oauth] cancelled by new start or explicit stop"); + return; + } + accept = listener.accept() => { + let (mut socket, peer) = match accept { + Ok(pair) => pair, + Err(err) => { + log::warn!("[loopback-oauth] accept failed: {err}"); + continue; + } + }; + if !peer.ip().is_loopback() { + log::warn!("[loopback-oauth] rejecting non-loopback peer {peer}"); + let _ = socket.shutdown().await; + continue; + } + + let mut buf = vec![0u8; READ_BUFFER_BYTES]; + let read = match timeout(PER_CONNECTION_READ_TIMEOUT, socket.read(&mut buf)).await { + Ok(Ok(n)) => n, + Ok(Err(err)) => { + log::debug!("[loopback-oauth] read error from {peer}: {err}"); + continue; + } + Err(_) => { + log::debug!("[loopback-oauth] read timeout from {peer}"); + continue; + } + }; + if read == 0 { + continue; + } + + let head = String::from_utf8_lossy(&buf[..read]); + let target = match parse_request_target(&head) { + Some(t) => t.to_string(), + None => { + let _ = socket.write_all(&http_response("405 Method Not Allowed", "method not allowed")).await; + continue; + } + }; + + let (path, query) = match target.split_once('?') { + Some((p, q)) => (p, q), + None => (target.as_str(), ""), + }; + + if path != "/auth" { + let _ = socket.write_all(&http_response("404 Not Found", "not found")).await; + continue; + } + + match extract_state(query) { + Some(s) if s == expected_state => {} + _ => { + log::warn!("[loopback-oauth] /auth with missing or mismatched state — ignoring"); + let _ = socket.write_all(&http_response("400 Bad Request", "state mismatch")).await; + continue; + } + } + + let _ = socket.write_all(&http_response("200 OK", SUCCESS_BODY)).await; + let _ = socket.flush().await; + + let callback_url = format!("http://127.0.0.1:{}{}", bound_port, target); + if let Err(err) = app.emit(LOOPBACK_CALLBACK_EVENT, CallbackPayload { url: callback_url }) { + log::warn!("[loopback-oauth] emit callback event failed: {err}"); + } + return; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_get_request_target() { + let head = "GET /auth?token=abc&state=xyz HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"; + assert_eq!( + parse_request_target(head), + Some("/auth?token=abc&state=xyz") + ); + } + + #[test] + fn rejects_non_get_methods() { + let head = "POST /auth HTTP/1.1\r\n\r\n"; + assert_eq!(parse_request_target(head), None); + } + + #[test] + fn extracts_state_value() { + assert_eq!(extract_state("token=abc&state=xyz"), Some("xyz")); + assert_eq!(extract_state("state=only"), Some("only")); + assert_eq!(extract_state("token=abc"), None); + assert_eq!(extract_state(""), None); + } + + #[tokio::test] + async fn random_state_is_32_hex_chars() { + let s = random_state_nonce(); + assert_eq!(s.len(), 32); + assert!(s.chars().all(|c| c.is_ascii_hexdigit())); + } +} diff --git a/app/src/components/oauth/OAuthProviderButton.tsx b/app/src/components/oauth/OAuthProviderButton.tsx index 219d00c6fa..05546b69fc 100644 --- a/app/src/components/oauth/OAuthProviderButton.tsx +++ b/app/src/components/oauth/OAuthProviderButton.tsx @@ -10,6 +10,8 @@ import { } from '../../store/deepLinkAuthState'; import type { OAuthProviderConfig } from '../../types/oauth'; import { IS_DEV } from '../../utils/config'; +import { handleDeepLinkUrls } from '../../utils/desktopDeepLinkListener'; +import { startLoopbackOauthListener } from '../../utils/loopbackOauthListener'; import { prepareOAuthLoginLaunch } from '../../utils/oauthAppVersionGate'; import { openUrl } from '../../utils/openUrl'; import { isTauri } from '../../utils/tauriCommands'; @@ -229,7 +231,36 @@ const OAuthProviderButton = ({ // hit a Tauri IPC round-trip and the result hasn't changed within a // single click handler. const backendUrl = preflight.backendUrl; - const loginUrl = `${backendUrl}/auth/${provider.id}/login${IS_DEV ? '?responseType=json' : ''}`; + // Prefer a loopback HTTP redirect (RFC 8252) over the openhuman:// deep + // link: deep links are unpredictable on Linux/Windows and rely on + // single-instance forwarding through a named pipe (#1130). If bind + // fails (port in use, not in Tauri, etc.) we fall back to the legacy + // deep-link path the backend already supports. + const loopback = isTauri() ? await startLoopbackOauthListener() : null; + const loginUrlBase = `${backendUrl}/auth/${provider.id}/login`; + const params = new URLSearchParams(); + if (IS_DEV) params.set('responseType', 'json'); + if (loopback) params.set('redirectUri', loopback.redirectUri); + const loginUrl = params.toString() ? `${loginUrlBase}?${params}` : loginUrlBase; + + if (loopback) { + // Race the loopback callback against the existing focus/timeout reset + // path. Browser hits 127.0.0.1 -> shell emits event -> we feed the URL + // through the same handler the openhuman:// path uses, so token + // exchange and CoreStateProvider commit logic stays in one place. + void loopback + .awaitCallback() + .then(callbackUrl => { + const synthetic = callbackUrl.replace( + /^https?:\/\/127\.0\.0\.1:\d+\/auth/, + 'openhuman://auth' + ); + void handleDeepLinkUrls([synthetic]); + }) + .catch(err => { + warnLog('[%s] loopback callback failed', provider.id, err); + }); + } if (IS_DEV) { console.log(`[dev] OAuth debug mode enabled. OAuth URL: ${loginUrl}`); diff --git a/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx b/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx index e3d113e086..e172f47b00 100644 --- a/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx +++ b/app/src/components/oauth/__tests__/OAuthProviderButton.test.tsx @@ -7,6 +7,8 @@ import { completeDeepLinkAuthProcessing, getDeepLinkAuthState, } from '../../../store/deepLinkAuthState'; +import { handleDeepLinkUrls } from '../../../utils/desktopDeepLinkListener'; +import { startLoopbackOauthListener } from '../../../utils/loopbackOauthListener'; import { prepareOAuthLoginLaunch } from '../../../utils/oauthAppVersionGate'; import { openUrl } from '../../../utils/openUrl'; import { isTauri } from '../../../utils/tauriCommands'; @@ -22,6 +24,10 @@ vi.mock('../../../utils/oauthAppVersionGate', () => ({ vi.mock('../../../utils/tauriCommands', () => ({ isTauri: vi.fn() })); +vi.mock('../../../utils/loopbackOauthListener', () => ({ startLoopbackOauthListener: vi.fn() })); + +vi.mock('../../../utils/desktopDeepLinkListener', () => ({ handleDeepLinkUrls: vi.fn() })); + vi.mock('../../../store/deepLinkAuthState', () => ({ beginDeepLinkAuthProcessing: vi.fn(), completeDeepLinkAuthProcessing: vi.fn(), @@ -386,4 +392,56 @@ describe('OAuthProviderButton', () => { /OpenHuman cloud sign-in is temporarily unavailable/i ); }); + + it('appends redirectUri and routes loopback callback through handleDeepLinkUrls', async () => { + let resolveCallback: ((url: string) => void) | null = null; + vi.mocked(startLoopbackOauthListener).mockResolvedValue({ + redirectUri: 'http://127.0.0.1:53824/auth?state=abc', + state: 'abc', + awaitCallback: () => + new Promise(resolve => { + resolveCallback = resolve; + }), + cancel: vi.fn().mockResolvedValue(undefined), + }); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Google' })); + + await act(async () => { + for (let i = 0; i < 8; i++) await Promise.resolve(); + }); + + expect(openUrl).toHaveBeenCalledWith( + expect.stringContaining('redirectUri=http%3A%2F%2F127.0.0.1%3A53824%2Fauth%3Fstate%3Dabc') + ); + + // Simulate the shell emitting the callback for this listener. + await act(async () => { + resolveCallback!('http://127.0.0.1:53824/auth?token=jwt&state=abc'); + for (let i = 0; i < 4; i++) await Promise.resolve(); + }); + + expect(handleDeepLinkUrls).toHaveBeenCalledWith(['openhuman://auth?token=jwt&state=abc']); + }); + + it('swallows loopback awaitCallback rejection without surfacing an error', async () => { + vi.mocked(startLoopbackOauthListener).mockResolvedValue({ + redirectUri: 'http://127.0.0.1:53824/auth?state=x', + state: 'x', + awaitCallback: () => Promise.reject(new Error('loopback gone')), + cancel: vi.fn().mockResolvedValue(undefined), + }); + + render(); + fireEvent.click(screen.getByRole('button', { name: 'Google' })); + + await act(async () => { + for (let i = 0; i < 8; i++) await Promise.resolve(); + }); + + expect(openUrl).toHaveBeenCalledTimes(1); + expect(handleDeepLinkUrls).not.toHaveBeenCalled(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); }); diff --git a/app/src/lib/i18n/chunks/de-5.ts b/app/src/lib/i18n/chunks/de-5.ts index 344d416e7e..2bbee687c5 100644 --- a/app/src/lib/i18n/chunks/de-5.ts +++ b/app/src/lib/i18n/chunks/de-5.ts @@ -526,28 +526,6 @@ const de5: TranslationMap = { 'settings.mascot.colorYellow': 'Gelb', 'settings.mascot.libraryUnavailable': 'OpenHuman Bibliothek nicht verfügbar', 'settings.mascot.title': 'OpenHuman', - 'settings.developerMenu.mcpServer.title': 'MCP-Server', - 'settings.developerMenu.mcpServer.desc': - 'Externe MCP-Clients zur Verbindung mit OpenHuman konfigurieren', - 'settings.mcpServer.title': 'MCP-Server', - 'settings.mcpServer.toolsSectionTitle': 'Verfügbare Tools', - 'settings.mcpServer.toolsSectionDesc': - 'Tools, die über den MCP-Stdio-Server bereitgestellt werden, wenn openhuman-core mcp ausgeführt wird', - 'settings.mcpServer.configSectionTitle': 'Client-Konfiguration', - 'settings.mcpServer.configSectionDesc': - 'Wähle deinen MCP-Client aus, um den passenden Konfigurations-Schnipsel zu erzeugen', - 'settings.mcpServer.copySnippet': 'In die Zwischenablage kopieren', - 'settings.mcpServer.copied': 'Kopiert!', - 'settings.mcpServer.openConfigFile': 'Konfigurationsdatei öffnen', - 'settings.mcpServer.binaryPathNotFound': - 'OpenHuman-Binärdatei nicht gefunden. Wenn du aus dem Quellcode arbeitest, baue sie mit: cargo build --bin openhuman-core', - 'settings.mcpServer.openConfigError': 'Konfigurationsdatei konnte nicht geöffnet werden', - 'settings.mcpServer.clientClaudeDesktop': 'Claude Desktop', - 'settings.mcpServer.clientCursor': 'Cursor', - 'settings.mcpServer.clientCodex': 'Codex', - 'settings.mcpServer.clientZed': 'Zed', - 'settings.mcpServer.configFilePath': 'Konfigurationsdatei', - 'settings.mcpServer.clientSelectorAriaLabel': 'MCP-Client-Auswahl', }; export default de5; diff --git a/app/src/utils/__tests__/loopbackOauthListener.test.ts b/app/src/utils/__tests__/loopbackOauthListener.test.ts new file mode 100644 index 0000000000..0065559814 --- /dev/null +++ b/app/src/utils/__tests__/loopbackOauthListener.test.ts @@ -0,0 +1,131 @@ +import { invoke } from '@tauri-apps/api/core'; +import { listen } from '@tauri-apps/api/event'; +import { beforeEach, describe, expect, type Mock, test, vi } from 'vitest'; + +import { startLoopbackOauthListener } from '../loopbackOauthListener'; + +vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn(() => true) })); +vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn() })); + +type TauriInternalsHolder = { __TAURI_INTERNALS__?: { invoke: unknown } }; + +const mockInvoke = invoke as Mock; +const mockListen = listen as Mock; + +beforeEach(() => { + vi.clearAllMocks(); + // Satisfy the isTauri() bootstrap-gap check in utils/tauriCommands/common.ts. + const holder = window as unknown as TauriInternalsHolder; + holder.__TAURI_INTERNALS__ = { invoke: () => undefined }; +}); + +describe('startLoopbackOauthListener', () => { + test('returns null when shell bind fails (fallback to deep link)', async () => { + mockInvoke.mockRejectedValueOnce(new Error('bind 127.0.0.1:53824 failed: Address in use')); + + const handle = await startLoopbackOauthListener(); + + expect(handle).toBeNull(); + expect(mockInvoke).toHaveBeenCalledWith('start_loopback_oauth_listener', { + port: 53824, + timeoutSecs: 300, + }); + }); + + test('returns handle with redirect uri and state on success', async () => { + mockInvoke.mockResolvedValueOnce({ + redirectUri: 'http://127.0.0.1:53824/auth', + state: 'deadbeef', + }); + mockListen.mockResolvedValue(() => {}); + + const handle = await startLoopbackOauthListener(); + + expect(handle).not.toBeNull(); + expect(handle!.state).toBe('deadbeef'); + expect(handle!.redirectUri).toBe('http://127.0.0.1:53824/auth?state=deadbeef'); + }); + + test('awaitCallback resolves with URL when shell emits callback event', async () => { + mockInvoke.mockResolvedValueOnce({ + redirectUri: 'http://127.0.0.1:53824/auth', + state: 'state-1', + }); + let registered: ((event: { payload: { url: string } }) => void) | null = null; + mockListen.mockImplementation((_event, handler) => { + registered = handler; + return Promise.resolve(() => {}); + }); + + const handle = await startLoopbackOauthListener(); + const callbackPromise = handle!.awaitCallback(); + // Wait a microtask for listen() to register. + await Promise.resolve(); + registered!({ payload: { url: 'http://127.0.0.1:53824/auth?token=jwt&state=state-1' } }); + + await expect(callbackPromise).resolves.toBe( + 'http://127.0.0.1:53824/auth?token=jwt&state=state-1' + ); + }); + + test('cancel calls stop_loopback_oauth_listener', async () => { + mockInvoke + .mockResolvedValueOnce({ redirectUri: 'http://127.0.0.1:53824/auth', state: 's' }) + .mockResolvedValueOnce(undefined); + mockListen.mockResolvedValue(() => {}); + + const handle = await startLoopbackOauthListener(); + await handle!.cancel(); + + expect(mockInvoke).toHaveBeenNthCalledWith(2, 'stop_loopback_oauth_listener'); + }); + + test('cancel swallows stop_loopback_oauth_listener failure', async () => { + mockInvoke + .mockResolvedValueOnce({ redirectUri: 'http://127.0.0.1:53824/auth', state: 's' }) + .mockRejectedValueOnce(new Error('already stopped')); + mockListen.mockResolvedValue(() => {}); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + try { + const handle = await startLoopbackOauthListener(); + await expect(handle!.cancel()).resolves.toBeUndefined(); + expect(warn).toHaveBeenCalledWith('[loopback-oauth] stop failed', expect.any(Error)); + } finally { + warn.mockRestore(); + } + }); + + test('awaitCallback rejects when listen() rejects', async () => { + mockInvoke.mockResolvedValueOnce({ redirectUri: 'http://127.0.0.1:53824/auth', state: 's' }); + mockListen.mockRejectedValueOnce(new Error('listen failed')); + + const handle = await startLoopbackOauthListener(); + await expect(handle!.awaitCallback()).rejects.toThrow('listen failed'); + }); + + test('awaitCallback rejects on timeout and stops the listener', async () => { + vi.useFakeTimers(); + try { + mockInvoke + .mockResolvedValueOnce({ redirectUri: 'http://127.0.0.1:53824/auth', state: 's' }) + .mockResolvedValueOnce(undefined); + const unlisten = vi.fn(); + mockListen.mockResolvedValue(unlisten); + + const handle = await startLoopbackOauthListener({ timeoutSecs: 1 }); + const callbackPromise = handle!.awaitCallback(); + // Let listen() register. + await Promise.resolve(); + vi.advanceTimersByTime(1000); + + await expect(callbackPromise).rejects.toThrow('Loopback OAuth listener timed out'); + expect(unlisten).toHaveBeenCalledTimes(1); + // Drain the queued microtask that calls stop(). + await Promise.resolve(); + expect(mockInvoke).toHaveBeenNthCalledWith(2, 'stop_loopback_oauth_listener'); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/app/src/utils/desktopDeepLinkListener.ts b/app/src/utils/desktopDeepLinkListener.ts index ac4e4e1967..43da554841 100644 --- a/app/src/utils/desktopDeepLinkListener.ts +++ b/app/src/utils/desktopDeepLinkListener.ts @@ -319,7 +319,7 @@ const handleOAuthDeepLink = async (parsed: URL) => { * - `openhuman://payment/success?session_id=...` → Stripe payment confirmation * - `openhuman://payment/cancel` → Stripe payment cancellation */ -const handleDeepLinkUrls = async (urls: string[] | null | undefined) => { +export const handleDeepLinkUrls = async (urls: string[] | null | undefined) => { if (!urls || urls.length === 0) { return; } diff --git a/app/src/utils/loopbackOauthListener.ts b/app/src/utils/loopbackOauthListener.ts new file mode 100644 index 0000000000..71a0563da6 --- /dev/null +++ b/app/src/utils/loopbackOauthListener.ts @@ -0,0 +1,114 @@ +import { invoke } from '@tauri-apps/api/core'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; + +import { isTauri } from './tauriCommands/common'; + +/** + * Loopback OAuth listener — preferred desktop redirect target ahead of + * `openhuman://` deep links (RFC 8252). + * + * The Tauri shell binds `http://127.0.0.1:/auth` on demand, returns the + * redirect URI plus a state nonce, and emits a `loopback-oauth-callback` event + * once the backend redirects the browser back. Callers append the state to the + * URL handed to the backend so a hostile page on the same loopback origin + * cannot fake a callback. + * + * Falls back gracefully: any failure (not in Tauri, port already in use, + * timeout) returns `null` so callers can take the `openhuman://` deep-link + * path instead. + */ + +const DEFAULT_PORT = 53824; +const DEFAULT_TIMEOUT_SECS = 300; +const CALLBACK_EVENT = 'loopback-oauth-callback'; + +export interface LoopbackHandle { + /** Fully qualified redirect URI to give to the backend, state already appended. */ + redirectUri: string; + /** State nonce the backend must echo back as `?state=`. */ + state: string; + /** Resolves with the full callback URL once the browser hits the loopback. */ + awaitCallback: () => Promise; + /** Tear down the listener early (e.g. user cancelled). */ + cancel: () => Promise; +} + +interface StartResult { + redirectUri: string; + state: string; +} + +interface CallbackPayload { + url: string; +} + +export interface StartLoopbackOptions { + /** Loopback port to bind. Must be pre-registered with the backend. */ + port?: number; + /** How long to keep the listener alive. */ + timeoutSecs?: number; +} + +/** + * Start a one-shot loopback listener. Returns `null` if not running inside + * Tauri, or if the shell fails to bind (port in use, etc) — the caller should + * then fall back to the `openhuman://` deep-link redirect. + */ +export const startLoopbackOauthListener = async ( + options: StartLoopbackOptions = {} +): Promise => { + if (!isTauri()) { + return null; + } + + const port = options.port ?? DEFAULT_PORT; + const timeoutSecs = options.timeoutSecs ?? DEFAULT_TIMEOUT_SECS; + + let result: StartResult; + try { + result = await invoke('start_loopback_oauth_listener', { port, timeoutSecs }); + } catch (err) { + console.warn('[loopback-oauth] start failed, falling back to deep link', err); + return null; + } + + const redirectUriWithState = appendState(result.redirectUri, result.state); + + const stop = async () => { + try { + await invoke('stop_loopback_oauth_listener'); + } catch (err) { + console.warn('[loopback-oauth] stop failed', err); + } + }; + + const awaitCallback = (): Promise => + new Promise((resolve, reject) => { + let unlisten: UnlistenFn | null = null; + const timer = window.setTimeout(() => { + if (unlisten) unlisten(); + void stop(); + reject(new Error('Loopback OAuth listener timed out')); + }, timeoutSecs * 1000); + + listen(CALLBACK_EVENT, event => { + window.clearTimeout(timer); + if (unlisten) unlisten(); + resolve(event.payload.url); + }) + .then(fn => { + unlisten = fn; + }) + .catch(err => { + window.clearTimeout(timer); + reject(err); + }); + }); + + return { redirectUri: redirectUriWithState, state: result.state, awaitCallback, cancel: stop }; +}; + +const appendState = (uri: string, state: string): string => { + const separator = uri.includes('?') ? '&' : '?'; + return `${uri}${separator}state=${encodeURIComponent(state)}`; +};