mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-13 02:18:16 +01:00
Add SSO login options to desktop app (#4954)
# Description of Changes Add SSO login options to desktop app
This commit is contained in:
@@ -1,7 +1,13 @@
|
||||
use keyring::Entry;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
use tiny_http::{Response, Server};
|
||||
use sha2::{Sha256, Digest};
|
||||
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
use rand::{thread_rng, Rng};
|
||||
use rand::distributions::Alphanumeric;
|
||||
|
||||
const STORE_FILE: &str = "connection.json";
|
||||
const USER_INFO_KEY: &str = "user_info";
|
||||
@@ -177,12 +183,13 @@ pub async fn login(
|
||||
server_url: String,
|
||||
username: String,
|
||||
password: String,
|
||||
supabase_key: String,
|
||||
saas_server_url: 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");
|
||||
// Compare against the configured 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)" });
|
||||
|
||||
@@ -193,10 +200,6 @@ pub async fn login(
|
||||
// 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,
|
||||
@@ -205,7 +208,7 @@ pub async fn login(
|
||||
let response = client
|
||||
.post(&login_url)
|
||||
.header("Content-Type", "application/json;charset=UTF-8")
|
||||
.header("apikey", supabase_key)
|
||||
.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")
|
||||
@@ -301,3 +304,285 @@ pub async fn login(
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate PKCE code_verifier (random 43-128 character string)
|
||||
fn generate_code_verifier() -> String {
|
||||
thread_rng()
|
||||
.sample_iter(&Alphanumeric)
|
||||
.take(128)
|
||||
.map(char::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Generate PKCE code_challenge from code_verifier (SHA256 hash, base64url encoded)
|
||||
fn generate_code_challenge(code_verifier: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(code_verifier.as_bytes());
|
||||
let hash = hasher.finalize();
|
||||
URL_SAFE_NO_PAD.encode(hash)
|
||||
}
|
||||
|
||||
/// Opens the system browser for OAuth authentication with localhost callback server
|
||||
/// Uses 127.0.0.1 (loopback) which is supported by Google OAuth with any port
|
||||
/// Implements PKCE (Proof Key for Code Exchange) for secure OAuth flow
|
||||
#[tauri::command]
|
||||
pub async fn start_oauth_login(
|
||||
_app_handle: AppHandle,
|
||||
provider: String,
|
||||
auth_server_url: String,
|
||||
supabase_key: String,
|
||||
success_html: String,
|
||||
error_html: String,
|
||||
) -> Result<OAuthCallbackResult, String> {
|
||||
log::info!("Starting OAuth login for provider: {} with auth server: {}", provider, auth_server_url);
|
||||
|
||||
// Generate PKCE code_verifier and code_challenge
|
||||
let code_verifier = generate_code_verifier();
|
||||
let code_challenge = generate_code_challenge(&code_verifier);
|
||||
|
||||
log::debug!("PKCE code_verifier generated: {} chars", code_verifier.len());
|
||||
log::debug!("PKCE code_challenge: {}", code_challenge);
|
||||
|
||||
// Use port 0 to let OS assign an available port (avoids port reuse issues)
|
||||
// Supabase allows any localhost port via redirect_to parameter
|
||||
let server = Server::http("127.0.0.1:0")
|
||||
.map_err(|e| format!("Failed to create OAuth callback server: {}", e))?;
|
||||
|
||||
let port = match server.server_addr() {
|
||||
tiny_http::ListenAddr::IP(addr) => addr.port(),
|
||||
#[cfg(unix)]
|
||||
tiny_http::ListenAddr::Unix(_) => {
|
||||
return Err("OAuth callback server bound to Unix socket instead of TCP port".to_string())
|
||||
}
|
||||
};
|
||||
|
||||
let callback_url = format!("http://127.0.0.1:{}/callback", port);
|
||||
log::info!("OAuth callback URL: {}", callback_url);
|
||||
|
||||
// Build OAuth URL with authorization code flow + PKCE
|
||||
// Note: Use redirect_to (not redirect_uri) to tell Supabase where to redirect after processing
|
||||
// Supabase handles its own /auth/v1/callback internally
|
||||
// prompt=select_account forces Google to show account picker every time
|
||||
let oauth_url = format!(
|
||||
"{}/auth/v1/authorize?provider={}&redirect_to={}&code_challenge={}&code_challenge_method=S256&prompt=select_account",
|
||||
auth_server_url.trim_end_matches('/'),
|
||||
provider,
|
||||
urlencoding::encode(&callback_url),
|
||||
urlencoding::encode(&code_challenge)
|
||||
);
|
||||
|
||||
log::info!("Full OAuth URL: {}", oauth_url);
|
||||
log::info!("========================================");
|
||||
|
||||
// Open system browser
|
||||
if let Err(e) = tauri_plugin_opener::open_url(&oauth_url, None::<&str>) {
|
||||
log::error!("Failed to open browser: {}", e);
|
||||
return Err(format!("Failed to open browser: {}", e));
|
||||
}
|
||||
|
||||
// Wait for OAuth callback with timeout
|
||||
let result = Arc::new(Mutex::new(None));
|
||||
let result_clone = Arc::clone(&result);
|
||||
|
||||
// Spawn server handling in blocking thread
|
||||
let server_handle = std::thread::spawn(move || {
|
||||
log::info!("Waiting for OAuth callback...");
|
||||
|
||||
// Wait for callback (with timeout)
|
||||
for _ in 0..120 { // 2 minute timeout
|
||||
if let Ok(Some(request)) = server.recv_timeout(std::time::Duration::from_secs(1)) {
|
||||
let url_str = format!("http://127.0.0.1{}", request.url());
|
||||
log::debug!("Received OAuth callback: {}", url_str);
|
||||
|
||||
// Parse the authorization code from URL
|
||||
let callback_data = parse_oauth_callback(&url_str);
|
||||
|
||||
// Respond with appropriate HTML based on result
|
||||
let html_response = match &callback_data {
|
||||
Ok(_) => {
|
||||
log::info!("Successfully extracted authorization code");
|
||||
success_html.clone()
|
||||
}
|
||||
Err(error_msg) => {
|
||||
log::warn!("OAuth callback error: {}", error_msg);
|
||||
// Replace {error} placeholder with actual error message
|
||||
error_html.replace("{error}", error_msg)
|
||||
}
|
||||
};
|
||||
|
||||
let response = Response::from_string(html_response)
|
||||
.with_header(tiny_http::Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..]).unwrap())
|
||||
.with_header(tiny_http::Header::from_bytes(&b"Connection"[..], &b"close"[..]).unwrap());
|
||||
|
||||
let _ = request.respond(response);
|
||||
|
||||
// Store result and exit loop
|
||||
let mut result_lock = result_clone.lock().unwrap();
|
||||
*result_lock = Some(callback_data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for server thread to complete
|
||||
server_handle.join()
|
||||
.map_err(|_| "OAuth callback server thread panicked".to_string())?;
|
||||
|
||||
// Get result
|
||||
let callback_data = result.lock().unwrap().take()
|
||||
.ok_or_else(|| "OAuth callback timeout - no response received".to_string())?;
|
||||
|
||||
// Handle the callback data - exchange authorization code for tokens
|
||||
match callback_data? {
|
||||
OAuthCallbackData::Code { code, redirect_uri } => {
|
||||
log::info!("OAuth completed with authorization code flow, exchanging code...");
|
||||
exchange_code_for_token(&auth_server_url, &code, &redirect_uri, &code_verifier, &supabase_key).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct OAuthCallbackResult {
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
pub expires_in: Option<i64>,
|
||||
}
|
||||
|
||||
// Internal enum for handling authorization code flow
|
||||
#[derive(Debug, Clone)]
|
||||
enum OAuthCallbackData {
|
||||
Code { code: String, redirect_uri: String },
|
||||
}
|
||||
|
||||
/// Exchange authorization code for access token using PKCE
|
||||
async fn exchange_code_for_token(
|
||||
auth_server_url: &str,
|
||||
code: &str,
|
||||
_redirect_uri: &str,
|
||||
code_verifier: &str,
|
||||
supabase_key: &str,
|
||||
) -> Result<OAuthCallbackResult, String> {
|
||||
log::info!("Exchanging authorization code for access token with PKCE");
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
// grant_type goes in query string, not body!
|
||||
let token_url = format!("{}/auth/v1/token?grant_type=pkce", auth_server_url.trim_end_matches('/'));
|
||||
|
||||
// Body should be JSON with auth_code and code_verifier
|
||||
let body = serde_json::json!({
|
||||
"auth_code": code,
|
||||
"code_verifier": code_verifier,
|
||||
});
|
||||
|
||||
log::debug!("Token exchange URL: {}", token_url);
|
||||
log::debug!("Code verifier length: {} chars", code_verifier.len());
|
||||
|
||||
let response = client
|
||||
.post(&token_url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("apikey", supabase_key)
|
||||
.header("Authorization", format!("Bearer {}", supabase_key))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to exchange code for token: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
let error_text = response
|
||||
.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
log::error!("Token exchange failed with status {}: {}", status, error_text);
|
||||
return Err(format!("Token exchange failed: {}", error_text));
|
||||
}
|
||||
|
||||
// Parse token response
|
||||
let token_response: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse token response: {}", e))?;
|
||||
|
||||
log::info!("Token exchange successful");
|
||||
|
||||
let access_token = token_response
|
||||
.get("access_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "No access_token in token response".to_string())?
|
||||
.to_string();
|
||||
|
||||
let refresh_token = token_response
|
||||
.get("refresh_token")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let expires_in = token_response
|
||||
.get("expires_in")
|
||||
.and_then(|v| v.as_i64());
|
||||
|
||||
Ok(OAuthCallbackResult {
|
||||
access_token,
|
||||
refresh_token,
|
||||
expires_in,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_oauth_callback(url_str: &str) -> Result<OAuthCallbackData, String> {
|
||||
// Parse URL to extract authorization code or error
|
||||
let parsed_url = url::Url::parse(url_str)
|
||||
.map_err(|e| format!("Failed to parse callback URL: {}", e))?;
|
||||
|
||||
// Check for OAuth error first (error responses take precedence)
|
||||
let mut error = None;
|
||||
let mut error_description = None;
|
||||
let mut code = None;
|
||||
|
||||
for (key, value) in parsed_url.query_pairs() {
|
||||
match key.as_ref() {
|
||||
"error" => error = Some(value.to_string()),
|
||||
"error_description" => error_description = Some(value.to_string()),
|
||||
"code" => code = Some(value.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// If OAuth provider returned an error, fail immediately
|
||||
if let Some(error_code) = error {
|
||||
let error_msg = if let Some(description) = error_description {
|
||||
format!("OAuth authentication failed: {} - {}", error_code, description)
|
||||
} else {
|
||||
format!("OAuth authentication failed: {}", error_code)
|
||||
};
|
||||
log::error!("{}", error_msg);
|
||||
return Err(error_msg);
|
||||
}
|
||||
|
||||
// If we have a code, return it
|
||||
if let Some(auth_code) = code {
|
||||
log::info!("Found authorization code in callback");
|
||||
|
||||
// Reconstruct the redirect_uri (without query params) for token exchange
|
||||
let redirect_uri = if let Some(port) = parsed_url.port() {
|
||||
format!("{}://{}:{}{}",
|
||||
parsed_url.scheme(),
|
||||
parsed_url.host_str().unwrap_or("127.0.0.1"),
|
||||
port,
|
||||
parsed_url.path()
|
||||
)
|
||||
} else {
|
||||
format!("{}://{}{}",
|
||||
parsed_url.scheme(),
|
||||
parsed_url.host_str().unwrap_or("127.0.0.1"),
|
||||
parsed_url.path()
|
||||
)
|
||||
};
|
||||
|
||||
return Ok(OAuthCallbackData::Code {
|
||||
code: auth_code,
|
||||
redirect_uri,
|
||||
});
|
||||
}
|
||||
|
||||
// No authorization code or error found
|
||||
Err("No authorization code or error found in OAuth callback".to_string())
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ pub use auth::{
|
||||
login,
|
||||
save_auth_token,
|
||||
save_user_info,
|
||||
start_oauth_login,
|
||||
};
|
||||
pub use default_app::{is_default_pdf_handler, set_as_default_pdf_handler};
|
||||
pub use health::check_backend_health;
|
||||
|
||||
@@ -25,6 +25,7 @@ use commands::{
|
||||
set_connection_mode,
|
||||
set_as_default_pdf_handler,
|
||||
start_backend,
|
||||
start_oauth_login,
|
||||
};
|
||||
use state::connection_state::AppConnectionState;
|
||||
use utils::{add_log, get_tauri_logs};
|
||||
@@ -32,6 +33,12 @@ use utils::{add_log, get_tauri_logs};
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(
|
||||
tauri_plugin_log::Builder::new()
|
||||
.level(log::LevelFilter::Info)
|
||||
.build()
|
||||
)
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
@@ -95,6 +102,7 @@ pub fn run() {
|
||||
save_user_info,
|
||||
get_user_info,
|
||||
clear_user_info,
|
||||
start_oauth_login,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
@@ -115,6 +123,7 @@ pub fn run() {
|
||||
RunEvent::Opened { urls } => {
|
||||
add_log(format!("📂 Tauri file opened event: {:?}", urls));
|
||||
let mut added_files = false;
|
||||
|
||||
for url in urls {
|
||||
let url_str = url.as_str();
|
||||
if url_str.starts_with("file://") {
|
||||
|
||||
Reference in New Issue
Block a user