Files
Stirling-PDF/frontend/src-tauri/src/commands/auth.rs
T
James BruntonandGitHub e8e98128d2 Allow login to SaaS for desktop instead of offline mode (#4941)
# Description of Changes
Makes the desktop options to sign in with your Stirling account, or sign
into self-hosted:

<img width="608" height="456" alt="image"
src="https://github.com/user-attachments/assets/a49988ab-db3f-4333-b242-790aee5c07c6"
/>

The first option still runs everything locally, just enforces that
you've signed in for now. Future work will enable sending operations
that can't be run locally to the server.
2025-11-22 00:38:59 +00:00

304 lines
9.1 KiB
Rust

use keyring::Entry;
use serde::{Deserialize, Serialize};
use tauri::AppHandle;
use tauri_plugin_store::StoreExt;
const STORE_FILE: &str = "connection.json";
const USER_INFO_KEY: &str = "user_info";
const KEYRING_SERVICE: &str = "stirling-pdf";
const KEYRING_TOKEN_KEY: &str = "auth-token";
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UserInfo {
pub username: String,
pub email: Option<String>,
}
fn get_keyring_entry() -> Result<Entry, String> {
Entry::new(KEYRING_SERVICE, KEYRING_TOKEN_KEY)
.map_err(|e| format!("Failed to access keyring: {}", e))
}
#[tauri::command]
pub async fn save_auth_token(_app_handle: AppHandle, token: String) -> Result<(), String> {
log::info!("Saving auth token to keyring");
let entry = get_keyring_entry()?;
entry
.set_password(&token)
.map_err(|e| format!("Failed to save token to keyring: {}", e))?;
log::info!("Auth token saved successfully");
Ok(())
}
#[tauri::command]
pub async fn get_auth_token(_app_handle: AppHandle) -> Result<Option<String>, String> {
log::debug!("Retrieving auth token from keyring");
let entry = get_keyring_entry()?;
match entry.get_password() {
Ok(token) => Ok(Some(token)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(format!("Failed to retrieve token: {}", e)),
}
}
#[tauri::command]
pub async fn clear_auth_token(_app_handle: AppHandle) -> Result<(), String> {
log::info!("Clearing auth token from keyring");
let entry = get_keyring_entry()?;
// Delete the token - ignore error if it doesn't exist
match entry.delete_credential() {
Ok(_) => {
log::info!("Auth token cleared successfully");
Ok(())
}
Err(keyring::Error::NoEntry) => {
log::info!("Auth token was already cleared");
Ok(())
}
Err(e) => Err(format!("Failed to clear token: {}", e)),
}
}
#[tauri::command]
pub async fn save_user_info(
app_handle: AppHandle,
username: String,
email: Option<String>,
) -> Result<(), String> {
log::info!("Saving user info for: {}", username);
let user_info = UserInfo { username, email };
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
store.set(
USER_INFO_KEY,
serde_json::to_value(&user_info)
.map_err(|e| format!("Failed to serialize user info: {}", e))?,
);
store
.save()
.map_err(|e| format!("Failed to save store: {}", e))?;
log::info!("User info saved successfully");
Ok(())
}
#[tauri::command]
pub async fn get_user_info(app_handle: AppHandle) -> Result<Option<UserInfo>, String> {
log::debug!("Retrieving user info");
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
let user_info: Option<UserInfo> = store
.get(USER_INFO_KEY)
.and_then(|v| serde_json::from_value(v.clone()).ok());
Ok(user_info)
}
#[tauri::command]
pub async fn clear_user_info(app_handle: AppHandle) -> Result<(), String> {
log::info!("Clearing user info");
let store = app_handle
.store(STORE_FILE)
.map_err(|e| format!("Failed to access store: {}", e))?;
store.delete(USER_INFO_KEY);
store
.save()
.map_err(|e| format!("Failed to save store: {}", e))?;
log::info!("User info cleared successfully");
Ok(())
}
// Response types for Spring Boot login (self-hosted)
#[derive(Debug, Deserialize)]
struct SpringBootSession {
access_token: String,
}
#[derive(Debug, Deserialize)]
struct SpringBootUser {
username: String,
email: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SpringBootLoginResponse {
session: SpringBootSession,
user: SpringBootUser,
}
// Response types for Supabase login (SaaS)
#[derive(Debug, Deserialize)]
struct SupabaseUserMetadata {
full_name: Option<String>,
}
#[derive(Debug, Deserialize)]
struct SupabaseUser {
email: Option<String>,
user_metadata: Option<SupabaseUserMetadata>,
}
#[derive(Debug, Deserialize)]
struct SupabaseLoginResponse {
access_token: String,
user: SupabaseUser,
}
#[derive(Debug, Serialize)]
pub struct LoginResponse {
pub token: String,
pub username: String,
pub email: Option<String>,
}
/// Login command - makes HTTP request from Rust to bypass CORS
/// Supports both Supabase authentication (SaaS) and Spring Boot authentication (self-hosted)
#[tauri::command]
pub async fn login(
server_url: String,
username: String,
password: String,
) -> Result<LoginResponse, String> {
log::info!("Login attempt for user: {} to server: {}", username, server_url);
// Detect if this is Supabase (SaaS) or Spring Boot (self-hosted)
// Compare against the configured SaaS server URL from environment
let saas_server_url = env!("VITE_SAAS_SERVER_URL");
let is_supabase = server_url.trim_end_matches('/') == saas_server_url.trim_end_matches('/');
log::info!("Authentication type: {}", if is_supabase { "Supabase (SaaS)" } else { "Spring Boot (Self-hosted)" });
// Create HTTP client
let client = reqwest::Client::new();
if is_supabase {
// Supabase authentication flow
let login_url = format!("{}/auth/v1/token?grant_type=password", server_url.trim_end_matches('/'));
// Supabase public API key from environment variable (required at compile time)
// Set VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY before building
let supabase_key = env!("VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY");
let request_body = serde_json::json!({
"email": username,
"password": password,
});
let response = client
.post(&login_url)
.header("Content-Type", "application/json;charset=UTF-8")
.header("apikey", supabase_key)
.header("Authorization", format!("Bearer {}", supabase_key))
.header("X-Client-Info", "supabase-js-web/2.58.0")
.header("X-Supabase-Api-Version", "2024-01-01")
.json(&request_body)
.send()
.await
.map_err(|e| format!("Network error: {}", e))?;
let status = response.status();
if !status.is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
log::error!("Supabase login failed with status {}: {}", status, error_text);
return Err(if status.as_u16() == 400 || status.as_u16() == 401 {
"Invalid username or password".to_string()
} else if status.as_u16() == 403 {
"Access denied".to_string()
} else {
format!("Login failed: {}", status)
});
}
// Parse Supabase response format
let login_response: SupabaseLoginResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse Supabase response: {}", e))?;
let email = login_response.user.email.clone();
let username = login_response.user.user_metadata
.as_ref()
.and_then(|m| m.full_name.clone())
.or_else(|| email.clone())
.unwrap_or_else(|| username);
log::info!("Supabase login successful for user: {}", username);
Ok(LoginResponse {
token: login_response.access_token,
username,
email,
})
} else {
// Spring Boot authentication flow
let login_url = format!("{}/api/v1/auth/login", server_url.trim_end_matches('/'));
log::debug!("Spring Boot login URL: {}", login_url);
let response = client
.post(&login_url)
.json(&serde_json::json!({
"username": username,
"password": password,
}))
.send()
.await
.map_err(|e| format!("Network error: {}", e))?;
let status = response.status();
log::debug!("Spring Boot login response status: {}", status);
if !status.is_success() {
let error_text = response
.text()
.await
.unwrap_or_else(|_| "Unknown error".to_string());
log::error!("Spring Boot login failed with status {}: {}", status, error_text);
return Err(if status.as_u16() == 401 {
"Invalid username or password".to_string()
} else if status.as_u16() == 403 {
"Access denied".to_string()
} else {
format!("Login failed: {}", status)
});
}
// Parse Spring Boot response format
let login_response: SpringBootLoginResponse = response
.json()
.await
.map_err(|e| format!("Failed to parse Spring Boot response: {}", e))?;
log::info!("Spring Boot login successful for user: {}", login_response.user.username);
Ok(LoginResponse {
token: login_response.session.access_token,
username: login_response.user.username,
email: login_response.user.email,
})
}
}