Skip to content
5 changes: 4 additions & 1 deletion app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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")
Expand Down
304 changes: 304 additions & 0 deletions app/src-tauri/src/loopback_oauth.rs
Original file line number Diff line number Diff line change
@@ -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()));
}
}
33 changes: 32 additions & 1 deletion app/src/components/oauth/OAuthProviderButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}`);
Expand Down
Loading
Loading