Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/src-tauri/capabilities/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"updater:default",
"allow-core-process",
"allow-workspace-files",
"allow-app-update"
"allow-app-update",
"allow-loopback-oauth"
]
}
12 changes: 12 additions & 0 deletions app/src-tauri/permissions/allow-loopback-oauth.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[[permission]]
identifier = "allow-loopback-oauth"
description = "Permission to start / stop the one-shot http://127.0.0.1:<port>/auth listener used as the RFC 8252 OAuth callback target (see #2511). Narrow on purpose so consumers of the broader `allow-core-process` group do not inherit OAuth listener control."

[permission.commands]

allow = [
"start_loopback_oauth_listener",
"stop_loopback_oauth_listener",
]

deny = []
87 changes: 75 additions & 12 deletions app/src-tauri/src/loopback_oauth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ use tauri::Emitter;
use crate::AppRuntime;
type AppHandle = tauri::AppHandle<AppRuntime>;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::net::{TcpListener, TcpSocket};
use tokio::sync::oneshot;
use tokio::time::timeout;

Expand All @@ -40,15 +40,19 @@ const PER_CONNECTION_READ_TIMEOUT: Duration = Duration::from_secs(5);
struct ActiveListener {
id: u64,
tx: oneshot::Sender<()>,
done: Option<tauri::async_runtime::JoinHandle<()>>,
}

static NEXT_LISTENER_ID: AtomicU64 = AtomicU64::new(1);
static ACTIVE_LISTENER: Mutex<Option<ActiveListener>> = Mutex::new(None);

#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
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.
/// Serializes as `redirectUri` so the TS-side `result.redirectUri`
/// destructure works.
pub redirect_uri: String,
/// State nonce the backend must echo back as `?state=<value>`.
pub state: String,
Expand All @@ -61,18 +65,39 @@ struct CallbackPayload {
url: String,
}

fn cancel_active_listener() {
/// Signal the active listener to stop and return its join handle so the caller
/// can await its full teardown — critical when re-binding a fixed port, since
/// macOS releases the socket only after the owning task drops the listener.
fn take_active_listener() -> Option<tauri::async_runtime::JoinHandle<()>> {
if let Ok(mut guard) = ACTIVE_LISTENER.lock() {
if let Some(active) = guard.take() {
if let Some(mut active) = guard.take() {
let _ = active.tx.send(());
return active.done.take();
}
}
None
}

fn cancel_active_listener() {
let _ = take_active_listener();
}

fn install_active_listener(id: u64, tx: oneshot::Sender<()>) {
fn install_active_listener(
id: u64,
tx: oneshot::Sender<()>,
done: tauri::async_runtime::JoinHandle<()>,
) {
if let Ok(mut guard) = ACTIVE_LISTENER.lock() {
if let Some(old) = guard.replace(ActiveListener { id, tx }) {
if let Some(mut old) = guard.replace(ActiveListener {
id,
tx,
done: Some(done),
}) {
let _ = old.tx.send(());
// The previous listener's join handle is dropped here without an
// await — only the new-start path needs to await teardown. Stray
// installs (none today) would simply leak the wait, not break.
old.done.take();
}
}
}
Expand All @@ -88,6 +113,25 @@ fn clear_active_listener(id: u64) {
}
}

/// Bind a loopback TCP listener on the given port (or 0 for ephemeral). Sets
/// SO_REUSEADDR so re-binding the same port soon after a previous listener
/// dropped doesn't trip EADDRINUSE on the TIME_WAIT window.
fn bind_loopback(port: u16) -> Result<TcpListener, String> {
let sock_addr: std::net::SocketAddr = format!("127.0.0.1:{port}")
.parse()
.map_err(|err| format!("parse 127.0.0.1:{port} failed: {err}"))?;
let socket = TcpSocket::new_v4().map_err(|err| format!("TcpSocket::new_v4 failed: {err}"))?;
socket
.set_reuseaddr(true)
.map_err(|err| format!("set_reuseaddr failed: {err}"))?;
socket
.bind(sock_addr)
.map_err(|err| format!("bind 127.0.0.1:{port} failed: {err}"))?;
socket
.listen(16)
.map_err(|err| format!("listen on 127.0.0.1:{port} failed: {err}"))
}

fn random_state_nonce() -> String {
let mut bytes = [0u8; 16];
rand::rng().fill_bytes(&mut bytes);
Expand Down Expand Up @@ -136,12 +180,31 @@ pub async fn start_loopback_oauth_listener(
port: u16,
timeout_secs: u64,
) -> Result<StartResult, String> {
cancel_active_listener();
// Await the previous listener's task ending so the OS has actually
// released the fixed loopback port. SO_REUSEADDR alone is not enough on
// macOS — the prior socket must be dropped first.
if let Some(done) = take_active_listener() {
let _ = done.await;
}

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}"))?;
// Prefer the caller's requested port (so the backend allowlist, if any,
// matches) but fall back to an ephemeral OS-assigned port if the requested
// one is taken by another process (stale openhuman, second instance,
// unrelated service). The backend `redirectUri` whitelist restricts host
// but not port, so an ephemeral fallback is safe.
let listener: TcpListener = match bind_loopback(port) {
Ok(l) => l,
Err(primary_err) => {
log::warn!(
"[loopback-oauth] bind on requested port {port} failed ({primary_err}); retrying on ephemeral port"
);
bind_loopback(0).map_err(|err| {
format!(
"bind 127.0.0.1:{port} failed ({primary_err}); ephemeral fallback also 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.
Expand All @@ -156,10 +219,9 @@ pub async fn start_loopback_oauth_listener(

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 done = 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 {
Expand All @@ -171,6 +233,7 @@ pub async fn start_loopback_oauth_listener(
}
clear_active_listener(listener_id);
});
install_active_listener(listener_id, cancel_tx, done);

Ok(StartResult {
redirect_uri,
Expand Down
6 changes: 5 additions & 1 deletion app/src/components/oauth/OAuthProviderButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,11 @@ const OAuthProviderButton = ({
const loopback = isTauri() ? await startLoopbackOauthListener() : null;
const loginUrlBase = `${backendUrl}/auth/${provider.id}/login`;
const params = new URLSearchParams();
if (IS_DEV) params.set('responseType', 'json');
// `responseType=json` makes the backend return JSON in the browser tab
// instead of redirecting — useful as a pre-loopback dev workaround, but
// it shortcircuits the redirect so the loopback listener never fires.
// Only set it when we have no loopback handle (web build, or bind failed).
if (IS_DEV && !loopback) params.set('responseType', 'json');
if (loopback) params.set('redirectUri', loopback.redirectUri);
const loginUrl = params.toString() ? `${loginUrlBase}?${params}` : loginUrlBase;

Expand Down
188 changes: 188 additions & 0 deletions app/src/components/settings/LogoutAndClearActions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import debug from 'debug';
import { useId, useState } from 'react';

import { useT } from '../../lib/i18n/I18nContext';
import { useCoreState } from '../../providers/CoreStateProvider';
import { clearAllAppData } from '../../utils/clearAllAppData';
import SettingsMenuItem from './components/SettingsMenuItem';

const warnLog = debug('settings:account:warn');

/**
* Destructive account actions: Log out, and Log out + clear all app data.
* Lives at the bottom of the Settings → Account page. Owns its own modal
* state and confirmation flow so the parent page is just a list + this row.
*/
const LogoutAndClearActions = () => {
const { t } = useT();
const { clearSession, snapshot } = useCoreState();
const [showLogoutAndClearModal, setShowLogoutAndClearModal] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const modalTitleId = useId();

const handleLogout = async () => {
try {
await clearSession();
} catch (err) {
// Log only the message — `err` may carry stack frames / serialized
// backend payloads we don't want in renderer console.
const reason = err instanceof Error ? err.message : String(err);
warnLog('logout_failed %o', { reason });
setError(t('clearData.failedLogout'));
}
Comment thread
senamakel marked this conversation as resolved.
};

const handleLogoutAndClearData = async () => {
try {
setIsLoading(true);
setError(null);
const currentUserId = snapshot.auth.userId ?? snapshot.currentUser?._id ?? null;
await clearAllAppData({ clearSession, userId: currentUserId }); // restarts the app
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
setError(message || t('clearData.failed'));
} finally {
setIsLoading(false);
}
};

const arrowOutIcon = (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
);

// Inline error is only displayed below the row when the clear-data modal is
// closed — when the modal is open, it owns the error display. Without this
// surface, a `handleLogout` failure would set `error` but the user would
// never see it.
const showInlineError = error !== null && !showLogoutAndClearModal;

return (
<div className="mt-6">
<SettingsMenuItem
icon={arrowOutIcon}
title={t('settings.clearAppData')}
description={t('settings.clearAppDataDesc')}
onClick={() => setShowLogoutAndClearModal(true)}
testId="settings-nav-logout-and-clear"
dangerous
isFirst
/>
<SettingsMenuItem
icon={arrowOutIcon}
title={t('settings.logOut')}
description={t('settings.logOutDesc')}
onClick={handleLogout}
testId="settings-nav-logout"
dangerous
isLast
/>

{showInlineError && (
<div
role="alert"
data-testid="logout-error"
className="mt-3 mx-1 p-3 rounded-lg bg-coral-100 dark:bg-coral-500/20 border border-coral-500/20">
<p className="text-coral-600 dark:text-coral-300 text-sm">{error}</p>
</div>
)}

{showLogoutAndClearModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/30">
<div
role="dialog"
aria-modal="true"
aria-labelledby={modalTitleId}
className="bg-white dark:bg-neutral-900 rounded-2xl max-w-md w-full p-6 border border-stone-200 dark:border-neutral-800">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-lg bg-amber-100 dark:bg-amber-500/20 flex items-center justify-center">
<svg
className="w-5 h-5 text-amber-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
</div>
<div>
<h3
id={modalTitleId}
className="text-lg font-semibold text-stone-900 dark:text-neutral-100">
{t('clearData.title')}
</h3>
</div>
</div>

<div className="mb-6">
<div className="text-stone-700 dark:text-neutral-200 text-sm leading-relaxed">
<p>{t('clearData.warning')}</p>
<ul className="list-disc pl-5 mt-2 space-y-1">
<li>{t('clearData.bulletSettings')}</li>
<li>{t('clearData.bulletCache')}</li>
<li>{t('clearData.bulletWorkspace')}</li>
<li>{t('clearData.bulletOther')}</li>
</ul>
<p className="mt-3">{t('clearData.irreversible')}</p>
</div>

{error && (
<div className="mt-3 p-3 rounded-lg bg-coral-100 dark:bg-coral-500/20 border border-coral-500/20">
<p className="text-coral-600 dark:text-coral-300 text-sm">{error}</p>
</div>
)}
</div>

<div className="flex gap-3">
<button
onClick={() => {
setShowLogoutAndClearModal(false);
setError(null);
}}
disabled={isLoading}
className="flex-1 px-4 py-2 rounded-lg border border-stone-200 dark:border-neutral-800 text-stone-700 dark:text-neutral-200 hover:bg-stone-100 dark:hover:bg-neutral-800 dark:bg-neutral-800 dark:hover:bg-neutral-800 transition-colors disabled:opacity-50">
{t('common.cancel')}
</button>
<button
onClick={handleLogoutAndClearData}
disabled={isLoading}
className="flex-1 px-4 py-2 rounded-sm bg-amber-600 hover:bg-amber-500 text-white transition-colors disabled:opacity-50 flex items-center justify-center gap-2">
{isLoading && (
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
)}
{isLoading ? t('clearData.clearing') : t('clearData.title')}
</button>
</div>
</div>
</div>
)}
</div>
);
};

export default LogoutAndClearActions;
Loading
Loading