use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use flate2::{read::GzDecoder, write::GzEncoder, Compression}; use keyring::Entry; use serde::{Deserialize, Serialize}; use std::io::{Read, Write}; use std::time::{SystemTime, UNIX_EPOCH}; use thiserror::Error; use tracing::{error, info}; const SERVICE_NAME: &str = "friendolls"; #[derive(Debug, Error)] pub enum AuthError { #[error("Keyring error: {0}")] KeyringError(#[from] keyring::Error), #[error("Network error: {0}")] NetworkError(#[from] reqwest::Error), #[error("JSON serialization error: {0}")] SerializationError(#[from] serde_json::Error), #[error("Invalid app configuration")] InvalidConfig, #[error("Failed to refresh token")] RefreshFailed, #[error("Request failed: {0}")] RequestFailed(String), #[error("Missing callback parameter: {0}")] MissingParameter(String), #[error("Authentication flow cancelled")] Cancelled, #[error("Callback timeout - no response received")] CallbackTimeout, #[error("Server binding failed: {0}")] ServerBindError(String), #[error("Failed to open auth portal: {0}")] OpenPortalFailed(#[from] tauri_plugin_opener::Error), #[error("IO error: {0}")] IoError(#[from] std::io::Error), } #[derive(Debug, Clone, Deserialize, Serialize)] pub struct AuthPass { pub access_token: String, pub expires_in: u64, #[serde(default)] pub refresh_token: Option, #[serde(default)] pub refresh_expires_in: Option, pub issued_at: Option, } pub(crate) fn build_auth_pass( access_token: String, expires_in: u64, refresh_token: String, refresh_expires_in: u64, ) -> Result { let issued_at = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|_| AuthError::RefreshFailed)? .as_secs(); Ok(AuthPass { access_token, expires_in, refresh_token: Some(refresh_token), refresh_expires_in: Some(refresh_expires_in), issued_at: Some(issued_at), }) } pub fn save_auth_pass(auth_pass: &AuthPass) -> Result<(), AuthError> { let json = serde_json::to_string(auth_pass)?; let mut encoder = GzEncoder::new(Vec::new(), Compression::best()); encoder .write_all(json.as_bytes()) .map_err(|e| AuthError::SerializationError(serde_json::Error::io(e)))?; let compressed = encoder .finish() .map_err(|e| AuthError::SerializationError(serde_json::Error::io(e)))?; let encoded = URL_SAFE_NO_PAD.encode(&compressed); #[cfg(target_os = "windows")] { const CHUNK_SIZE: usize = 1200; let chunks: Vec<&str> = encoded .as_bytes() .chunks(CHUNK_SIZE) .map(|chunk| std::str::from_utf8(chunk).expect("base64 chunk is valid utf-8")) .collect(); let count_entry = Entry::new(SERVICE_NAME, "auth_pass_count")?; count_entry.set_password(&chunks.len().to_string())?; for (i, chunk) in chunks.iter().enumerate() { let entry = Entry::new(SERVICE_NAME, &format!("auth_pass_{}", i))?; entry.set_password(chunk)?; } } #[cfg(not(target_os = "windows"))] { let entry = Entry::new(SERVICE_NAME, "auth_pass")?; entry.set_password(&encoded)?; } Ok(()) } pub fn load_auth_pass() -> Result, AuthError> { #[cfg(target_os = "windows")] let encoded = { let count_entry = Entry::new(SERVICE_NAME, "auth_pass_count")?; let chunk_count = match count_entry.get_password() { Ok(count_str) => match count_str.parse::() { Ok(count) => count, Err(_) => { error!("Invalid chunk count in keyring"); return Ok(None); } }, Err(keyring::Error::NoEntry) => { info!("No auth pass found in keyring"); return Ok(None); } Err(e) => { error!("Failed to load chunk count from keyring"); return Err(AuthError::KeyringError(e)); } }; let mut encoded = String::new(); for i in 0..chunk_count { let entry = Entry::new(SERVICE_NAME, &format!("auth_pass_{}", i))?; match entry.get_password() { Ok(chunk) => encoded.push_str(&chunk), Err(e) => { error!("Failed to load chunk {} from keyring", i); return Err(AuthError::KeyringError(e)); } } } encoded }; #[cfg(not(target_os = "windows"))] let encoded = { let entry = Entry::new(SERVICE_NAME, "auth_pass")?; match entry.get_password() { Ok(pass) => pass, Err(keyring::Error::NoEntry) => { info!("No auth pass found in keyring"); return Ok(None); } Err(e) => { error!("Failed to load auth pass from keyring"); return Err(AuthError::KeyringError(e)); } } }; let compressed = match URL_SAFE_NO_PAD.decode(&encoded) { Ok(c) => c, Err(e) => { error!("Failed to base64 decode auth pass from keyring: {}", e); return Ok(None); } }; let mut decoder = GzDecoder::new(&compressed[..]); let mut json = String::new(); if let Err(e) = decoder.read_to_string(&mut json) { error!("Failed to decompress auth pass from keyring: {}", e); return Ok(None); } let auth_pass: AuthPass = match serde_json::from_str(&json) { Ok(v) => v, Err(_) => { error!("Failed to decode auth pass from keyring"); return Ok(None); } }; if auth_pass.refresh_token.is_none() || auth_pass.refresh_expires_in.is_none() { info!("Loaded legacy auth pass without refresh token support"); } Ok(Some(auth_pass)) } pub fn clear_auth_pass() -> Result<(), AuthError> { #[cfg(target_os = "windows")] { let count_entry = Entry::new(SERVICE_NAME, "auth_pass_count")?; let chunk_count = match count_entry.get_password() { Ok(count_str) => count_str.parse::().unwrap_or(0), Err(_) => 0, }; for i in 0..chunk_count { let entry = Entry::new(SERVICE_NAME, &format!("auth_pass_{}", i))?; let _ = entry.delete_credential(); } let _ = count_entry.delete_credential(); } #[cfg(not(target_os = "windows"))] { let entry = Entry::new(SERVICE_NAME, "auth_pass")?; let _ = entry.delete_credential(); } Ok(()) }