From 6730ad7cbbab6dd857e3255a6816a9bdfec68faa Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Thu, 7 May 2026 16:02:48 +0100 Subject: [PATCH] Desktop: persist auth token to disk when Credential Manager is restricted (#6303) Co-authored-by: James Brunton --- frontend/src-tauri/src/commands/auth.rs | 142 +++++++++++++++++------- 1 file changed, 102 insertions(+), 40 deletions(-) diff --git a/frontend/src-tauri/src/commands/auth.rs b/frontend/src-tauri/src/commands/auth.rs index e14fa4520..528f6d8de 100644 --- a/frontend/src-tauri/src/commands/auth.rs +++ b/frontend/src-tauri/src/commands/auth.rs @@ -13,6 +13,7 @@ const STORE_FILE: &str = "connection.json"; const USER_INFO_KEY: &str = "user_info"; const TOKENS_STORE_FILE: &str = "tokens.json"; const REFRESH_TOKEN_STORE_KEY: &str = "refresh_token"; +const AUTH_TOKEN_STORE_KEY: &str = "auth_token"; const KEYRING_SERVICE: &str = "stirling-pdf"; const KEYRING_TOKEN_KEY: &str = "auth-token"; const KEYRING_REFRESH_TOKEN_KEY: &str = "refresh-token"; @@ -40,73 +41,134 @@ fn get_refresh_token_keyring_entry() -> Result { } #[tauri::command] -pub async fn save_auth_token(_app_handle: AppHandle, token: String) -> Result<(), String> { +pub async fn save_auth_token(app_handle: AppHandle, token: String) -> Result<(), String> { let trimmed = token.trim(); if trimmed.is_empty() { log::warn!("Attempted to save empty auth token"); return Err("Token cannot be empty".to_string()); } - let entry = get_keyring_entry()?; - if trimmed.len() != token.len() { log::debug!("Auth token had surrounding whitespace; storing trimmed token"); } - entry - .set_password(trimmed) - .map_err(|e| { - log::error!("Failed to set password in keyring: {}", e); - format!("Failed to save token to keyring: {}", e) - })?; - - // Verify the save worked - match entry.get_password() { - Ok(retrieved_token) => { - if retrieved_token != trimmed { - log::error!("Token verification failed: Retrieved token doesn't match"); - return Err("Token verification failed after save".to_string()); + // Try keyring first (works in environments where Credential Manager persists writes). + // Any failure - including entry creation - falls through to the Tauri Store fallback + // below, never an early return, so restricted environments still persist the token. + match get_keyring_entry() { + Ok(entry) => match entry.set_password(trimmed) { + Ok(_) => { + // Verify it persists. On managed Win10 Pro environments (GPO restricting + // Credential Manager, AV/EDR rules, roaming-profile DPAPI quirks) the + // read-back can return the wrong value or NoEntry even when set succeeded. + match entry.get_password() { + Ok(saved) if saved == trimmed => { + // Clear any stale fallback copy so the keyring stays authoritative. + if let Ok(store) = app_handle.store(TOKENS_STORE_FILE) { + if store.get(AUTH_TOKEN_STORE_KEY).is_some() { + store.delete(AUTH_TOKEN_STORE_KEY); + let _ = store.save(); + } + } + log::info!("Auth token saved to keyring"); + return Ok(()); + } + _ => { + log::info!("Keyring did not persist auth token - using Tauri Store fallback"); + } + } } - } + Err(e) => { + log::info!("Keyring set failed for auth token: {} - using Tauri Store fallback", e); + } + }, Err(e) => { - log::error!("Token verification failed: {}", e); - return Err(format!("Token verification failed: {}", e)); + log::info!("Keyring entry unavailable for auth token: {} - using Tauri Store fallback", e); } } + // Fallback to Tauri Store (same pattern as save_refresh_token) + let store = app_handle + .store(TOKENS_STORE_FILE) + .map_err(|e| format!("Failed to access tokens store: {}", e))?; + + store.set( + AUTH_TOKEN_STORE_KEY, + serde_json::to_value(trimmed) + .map_err(|e| format!("Failed to serialize token: {}", e))?, + ); + + store + .save() + .map_err(|e| format!("Failed to save tokens store: {}", e))?; + + log::info!("Auth token saved to Tauri Store (fallback)"); Ok(()) } #[tauri::command] -pub async fn get_auth_token(_app_handle: AppHandle) -> Result, String> { - let entry = get_keyring_entry()?; - - match entry.get_password() { - Ok(token) => Ok(Some(token)), - Err(keyring::Error::NoEntry) => Ok(None), - Err(e) => { - log::error!("Failed to retrieve token from keyring: {}", e); - Err(format!("Failed to retrieve token: {}", e)) +pub async fn get_auth_token(app_handle: AppHandle) -> Result, String> { + // Try keyring first (production / unrestricted environments). Any failure - + // including entry creation - falls through to the Tauri Store fallback below. + match get_keyring_entry() { + Ok(entry) => match entry.get_password() { + Ok(token) => return Ok(Some(token)), + Err(keyring::Error::NoEntry) => { + log::debug!("No auth token in keyring, trying Tauri Store"); + } + Err(e) => { + log::warn!("Keyring error reading auth token: {} - trying Tauri Store", e); + } }, + Err(e) => { + log::warn!("Keyring entry unavailable for auth token: {} - trying Tauri Store", e); + } } + + // Fallback to Tauri Store + let store = app_handle + .store(TOKENS_STORE_FILE) + .map_err(|e| format!("Failed to access tokens store: {}", e))?; + + let token: Option = store + .get(AUTH_TOKEN_STORE_KEY) + .and_then(|v| serde_json::from_value(v.clone()).ok()); + + Ok(token) } #[tauri::command] -pub async fn clear_auth_token(_app_handle: AppHandle) -> Result<(), String> { - let entry = get_keyring_entry()?; - - // Delete the token - ignore error if it doesn't exist - match entry.delete_credential() { - Ok(_) | Err(keyring::Error::NoEntry) => Ok(()), - Err(e) => { - log::warn!("Failed to delete keyring credential: {}. Attempting overwrite with empty token.", e); - // As a fallback, overwrite with an empty token so a stale value cannot be reused - match entry.set_password("") { - Ok(_) => Ok(()), - Err(e2) => Err(format!("Failed to clear token (delete + overwrite failed): {}", e2)), +pub async fn clear_auth_token(app_handle: AppHandle) -> Result<(), String> { + // Clear from keyring (best-effort). Entry creation failure must not block clearing + // the disk fallback, otherwise a stale fallback token could survive sign-out. + match get_keyring_entry() { + Ok(entry) => match entry.delete_credential() { + Ok(_) | Err(keyring::Error::NoEntry) => {} + Err(e) => { + log::warn!("Failed to delete keyring credential: {}. Attempting overwrite with empty token.", e); + // Overwrite with an empty token so a stale value cannot be reused + if let Err(e2) = entry.set_password("") { + log::warn!("Failed to overwrite keyring auth token: {}", e2); + } } }, + Err(e) => { + log::warn!("Keyring entry unavailable while clearing auth token: {} - clearing Tauri Store fallback only", e); + } } + + // Clear from Tauri Store fallback + let store = app_handle + .store(TOKENS_STORE_FILE) + .map_err(|e| format!("Failed to access tokens store: {}", e))?; + + store.delete(AUTH_TOKEN_STORE_KEY); + + store + .save() + .map_err(|e| format!("Failed to save tokens store: {}", e))?; + + Ok(()) } #[tauri::command]