Add SSO login options to desktop app (#4954)

# Description of Changes
Add SSO login options to desktop app
This commit is contained in:
James Brunton
2025-11-25 11:56:25 +00:00
committed by GitHub
parent 2534c532b7
commit 64d343b765
12 changed files with 753 additions and 29 deletions

View File

@@ -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())
}

View File

@@ -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;

View File

@@ -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://") {