mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(auth): loopback OAuth redirect with deep-link fallback (#2511)
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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:<port>`, 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<AppRuntime>;
|
||||
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<Option<ActiveListener>> = 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=<value>`.
|
||||
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 = "<!doctype html><meta charset=utf-8><title>Signed in</title>\
|
||||
<body style=\"font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;color:#1c1c1e;background:#f5f5f7\">\
|
||||
<div style=\"text-align:center\"><h2 style=\"margin:0 0 8px\">You're signed in.</h2>\
|
||||
<p style=\"margin:0;color:#6e6e73\">You can close this tab and return to OpenHuman.</p></div>\
|
||||
<script>setTimeout(function(){window.close()},250)</script></body>";
|
||||
|
||||
fn http_response(status: &str, body: &str) -> Vec<u8> {
|
||||
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<StartResult, String> {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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<string>(resolve => {
|
||||
resolveCallback = resolve;
|
||||
}),
|
||||
cancel: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
render(<OAuthProviderButton provider={stubProvider} />);
|
||||
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(<OAuthProviderButton provider={stubProvider} />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:<port>/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=<value>`. */
|
||||
state: string;
|
||||
/** Resolves with the full callback URL once the browser hits the loopback. */
|
||||
awaitCallback: () => Promise<string>;
|
||||
/** Tear down the listener early (e.g. user cancelled). */
|
||||
cancel: () => Promise<void>;
|
||||
}
|
||||
|
||||
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<LoopbackHandle | null> => {
|
||||
if (!isTauri()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const port = options.port ?? DEFAULT_PORT;
|
||||
const timeoutSecs = options.timeoutSecs ?? DEFAULT_TIMEOUT_SECS;
|
||||
|
||||
let result: StartResult;
|
||||
try {
|
||||
result = await invoke<StartResult>('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<string> =>
|
||||
new Promise<string>((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<CallbackPayload>(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)}`;
|
||||
};
|
||||
Reference in New Issue
Block a user