diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 3b60cd6..890d8f3 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -1977,8 +1977,14 @@ fn key_state_name() -> &'static str { } fn cmd_logout() -> Result<()> { - auth::logout()?; - println!("Stored session cleared."); + if auth::logout()? { + println!("Signed out: the session is ended on Proton and cleared here."); + } else { + println!( + "Stored session cleared. Proton could not be reached to end the session; \ + it stays valid until it expires or is signed out in your account's security settings." + ); + } Ok(()) } diff --git a/crates/pdfs-core/src/auth.rs b/crates/pdfs-core/src/auth.rs index 796db3c..5acb3a0 100644 --- a/crates/pdfs-core/src/auth.rs +++ b/crates/pdfs-core/src/auth.rs @@ -516,8 +516,59 @@ pub fn lock(forget: bool) -> Result<()> { Ok(()) } -/// Forget the persisted session (best-effort; absent entry is not an error). -pub fn logout() -> Result<()> { +/// How long [`logout`] waits for Proton to end the session before it signs +/// out locally without. +const END_SESSION_TIMEOUT: Duration = Duration::from_secs(10); + +/// End the stored session on Proton's side (`DELETE auth/v4`). Returns false +/// when there is none or Proton could not be reached in time. +/// +/// Runs on a thread of its own, so it works the same whether the caller is +/// inside a tokio runtime or not. +fn end_server_session() -> bool { + let Ok(stored) = load() else { + return false; + }; + let ended = std::thread::scope(|scope| { + scope + .spawn(|| -> std::result::Result<(), String> { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| e.to_string())?; + rt.block_on(async { + let session = resume_stored(&stored).map_err(|e| e.to_string())?; + match tokio::time::timeout(END_SESSION_TIMEOUT, session.end()).await { + Ok(result) => result.map_err(|e| e.to_string()), + Err(_) => Err(format!( + "no answer within {}s", + END_SESSION_TIMEOUT.as_secs() + )), + } + }) + }) + .join() + .unwrap_or_else(|_| Err("the sign-out thread panicked".into())) + }); + match ended { + Ok(()) => true, + Err(error) => { + tracing::warn!(%error, "ending the session on Proton failed; signing out locally"); + false + } + } +} + +/// End the session on Proton, then forget it here. Returns whether Proton +/// ended it: offline, the session stays valid on Proton's side until it +/// expires or is signed out from the account's security settings. +/// +/// Before this, signing out only deleted the keyring entry. The tokens stayed +/// valid on Proton, so a copy of them taken from this machine still worked. +/// +/// The local half is best effort; an absent entry is not an error. +pub fn logout() -> Result { + let ended = end_server_session(); // The persisted entity cache describes the account being logged out of. It // is encrypted under a key derived from that account's passphrases, but // leaving it behind would also mean the next account inherits a store it can @@ -539,7 +590,7 @@ pub fn logout() -> Result<()> { } let _ = kernelkey::UNSAVED_TOKENS.clear(); match keyring_entry()?.delete_credential() { - Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(()), + Ok(()) | Err(keyring_core::Error::NoEntry) => Ok(ended), Err(e) => Err(e.into()), } } diff --git a/crates/pdfs-gui/src/app/pages/login.rs b/crates/pdfs-gui/src/app/pages/login.rs index dcfa91d..8200a8c 100644 --- a/crates/pdfs-gui/src/app/pages/login.rs +++ b/crates/pdfs-gui/src/app/pages/login.rs @@ -602,7 +602,7 @@ pub(crate) fn sign_out(ui: &Rc) { glib::spawn_future_local(async move { let result = gio::spawn_blocking(|| { service::disable_stop(); - auth::logout().map_err(|e| e.to_string()) + auth::logout().map(|_| ()).map_err(|e| e.to_string()) }) .await; if let Ok(Err(e)) = result { diff --git a/docs/BUGS.md b/docs/BUGS.md index e93be49..9998ea2 100644 --- a/docs/BUGS.md +++ b/docs/BUGS.md @@ -100,6 +100,7 @@ a live account since. | Entry | Title | |---|---| +| [B233](#b233--signing-out-leaves-the-session-valid-on-proton) | Signing out leaves the session valid on Proton | | [B232](#b232--names-with--or--show-as-empty-rows-or-are-drawn-as-markup) | Names with & or < show as empty rows or are drawn as markup | | [B231](#b231--a-large-upload-under-a-low-upload-limit-never-finishes) | A large upload under a low upload limit never finishes | | [B230](#b230--a-create-queued-offline-adopts-another-devices-file-of-the-same-name) | A create queued offline adopts another device's file of the same name | @@ -335,6 +336,29 @@ a live account since. Newest first. +### B233 — Signing out leaves the session valid on Proton + +**Status:** Fixed, not verified live +**Found:** 2026-10-16, architecture audit (`FINDINGS.md` section 3, logout). +**Where:** `logout` and `end_server_session` in `crates/pdfs-core/src/auth.rs`; `cmd_logout` in +`crates/pdfs-cli/src/main.rs` + +**Repro:** Copy the keyring entry, run `pdfs logout` (or **Sign Out** in the app), and resume a +session from the copy. + +**Cause.** Signing out only deleted the keyring entry and the local caches. The session was never +ended on Proton's side, so its tokens stayed valid until they expired. The SDK has the call for +it (`ProtonApiSession::end`, `DELETE auth/v4`); nothing used it. + +**Fix.** Logout resumes the stored session and ends it on Proton before it clears anything +locally. The call is bounded to 10 s and runs on a thread of its own. When Proton cannot be +reached, the local sign-out still happens, and `pdfs logout` says that the session stays valid +until it expires or is signed out in the account's security settings. + +**Test:** none automated; ending a session needs a live account. + +--- + ### B232 — Names with & or < show as empty rows or are drawn as markup **Status:** Fixed, not verified live diff --git a/docs/CLI.md b/docs/CLI.md index 29031cb..312da01 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -36,7 +36,7 @@ talks to the running service (`proton-drive.service`); `login`, `logout`, `mount | Command | Description | |---|---| | `pdfs login [-u EMAIL]` | Sign in (password, then a 2FA code if the account has one) and store the session in the system keyring. Restarts the service if it is running. | -| `pdfs logout` | Remove the stored session. | +| `pdfs logout` | End the session on Proton and remove the stored session. | | `pdfs status` | Account, mount, queue and cache state. | | `pdfs quota` | Storage used across all Proton products. | | `pdfs locations` | Every local place the service occupies: the Proton Drive folder and each synced folder, with mode and state. |