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
+102 -40
View File
@@ -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<Entry, String> {
}
#[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<Option<String>, 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<Option<String>, 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<String> = 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]