Desktop: persist auth token to disk when Credential Manager is restricted (#6303)

Co-authored-by: James Brunton <[email protected]>
This commit is contained in:
ConnorYoh
2026-05-07 16:02:48 +01:00
committed by GitHub
co-authored by James Brunton
parent 8fb9fced35
commit 6730ad7cbb
+99 -37
View File
@@ -13,6 +13,7 @@ const STORE_FILE: &str = "connection.json";
const USER_INFO_KEY: &str = "user_info"; const USER_INFO_KEY: &str = "user_info";
const TOKENS_STORE_FILE: &str = "tokens.json"; const TOKENS_STORE_FILE: &str = "tokens.json";
const REFRESH_TOKEN_STORE_KEY: &str = "refresh_token"; const REFRESH_TOKEN_STORE_KEY: &str = "refresh_token";
const AUTH_TOKEN_STORE_KEY: &str = "auth_token";
const KEYRING_SERVICE: &str = "stirling-pdf"; const KEYRING_SERVICE: &str = "stirling-pdf";
const KEYRING_TOKEN_KEY: &str = "auth-token"; const KEYRING_TOKEN_KEY: &str = "auth-token";
const KEYRING_REFRESH_TOKEN_KEY: &str = "refresh-token"; const KEYRING_REFRESH_TOKEN_KEY: &str = "refresh-token";
@@ -40,75 +41,136 @@ fn get_refresh_token_keyring_entry() -> Result<Entry, String> {
} }
#[tauri::command] #[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(); let trimmed = token.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
log::warn!("Attempted to save empty auth token"); log::warn!("Attempted to save empty auth token");
return Err("Token cannot be empty".to_string()); return Err("Token cannot be empty".to_string());
} }
let entry = get_keyring_entry()?;
if trimmed.len() != token.len() { if trimmed.len() != token.len() {
log::debug!("Auth token had surrounding whitespace; storing trimmed token"); log::debug!("Auth token had surrounding whitespace; storing trimmed token");
} }
entry // Try keyring first (works in environments where Credential Manager persists writes).
.set_password(trimmed) // Any failure - including entry creation - falls through to the Tauri Store fallback
.map_err(|e| { // below, never an early return, so restricted environments still persist the token.
log::error!("Failed to set password in keyring: {}", e); match get_keyring_entry() {
format!("Failed to save token to keyring: {}", e) Ok(entry) => match entry.set_password(trimmed) {
})?; Ok(_) => {
// Verify it persists. On managed Win10 Pro environments (GPO restricting
// Verify the save worked // 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() { match entry.get_password() {
Ok(retrieved_token) => { Ok(saved) if saved == trimmed => {
if retrieved_token != trimmed { // Clear any stale fallback copy so the keyring stays authoritative.
log::error!("Token verification failed: Retrieved token doesn't match"); if let Ok(store) = app_handle.store(TOKENS_STORE_FILE) {
return Err("Token verification failed after save".to_string()); 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) => { Err(e) => {
log::error!("Token verification failed: {}", e); log::info!("Keyring set failed for auth token: {} - using Tauri Store fallback", e);
return Err(format!("Token verification failed: {}", e)); }
},
Err(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(()) Ok(())
} }
#[tauri::command] #[tauri::command]
pub async fn get_auth_token(_app_handle: AppHandle) -> Result<Option<String>, String> { pub async fn get_auth_token(app_handle: AppHandle) -> Result<Option<String>, String> {
let entry = get_keyring_entry()?; // Try keyring first (production / unrestricted environments). Any failure -
// including entry creation - falls through to the Tauri Store fallback below.
match entry.get_password() { match get_keyring_entry() {
Ok(token) => Ok(Some(token)), Ok(entry) => match entry.get_password() {
Err(keyring::Error::NoEntry) => Ok(None), Ok(token) => return Ok(Some(token)),
Err(e) => { Err(keyring::Error::NoEntry) => {
log::error!("Failed to retrieve token from keyring: {}", e); log::debug!("No auth token in keyring, trying Tauri Store");
Err(format!("Failed to retrieve token: {}", e))
},
} }
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<String> = store
.get(AUTH_TOKEN_STORE_KEY)
.and_then(|v| serde_json::from_value(v.clone()).ok());
Ok(token)
} }
#[tauri::command] #[tauri::command]
pub async fn clear_auth_token(_app_handle: AppHandle) -> Result<(), String> { pub async fn clear_auth_token(app_handle: AppHandle) -> Result<(), String> {
let entry = get_keyring_entry()?; // Clear from keyring (best-effort). Entry creation failure must not block clearing
// the disk fallback, otherwise a stale fallback token could survive sign-out.
// Delete the token - ignore error if it doesn't exist match get_keyring_entry() {
match entry.delete_credential() { Ok(entry) => match entry.delete_credential() {
Ok(_) | Err(keyring::Error::NoEntry) => Ok(()), Ok(_) | Err(keyring::Error::NoEntry) => {}
Err(e) => { Err(e) => {
log::warn!("Failed to delete keyring credential: {}. Attempting overwrite with empty token.", 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 // Overwrite with an empty token so a stale value cannot be reused
match entry.set_password("") { if let Err(e2) = entry.set_password("") {
Ok(_) => Ok(()), log::warn!("Failed to overwrite keyring auth token: {}", e2);
Err(e2) => Err(format!("Failed to clear token (delete + overwrite failed): {}", 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] #[tauri::command]
pub async fn save_refresh_token(app_handle: AppHandle, token: String) -> Result<(), String> { pub async fn save_refresh_token(app_handle: AppHandle, token: String) -> Result<(), String> {
log::info!("Saving refresh token - trying keyring first"); log::info!("Saving refresh token - trying keyring first");