feat(auth): loopback OAuth redirect with deep-link fallback (#2511)

This commit is contained in:
Steven Enamakel
2026-05-22 21:57:50 -07:00
committed by GitHub
parent 934546b2b3
commit bbec0d61f9
8 changed files with 644 additions and 25 deletions
+4 -1
View File
@@ -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")
+304
View File
@@ -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();
});
});
-22
View File
@@ -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();
}
});
});
+1 -1
View File
@@ -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;
}
+114
View File
@@ -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)}`;
};