diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 8dbc558dbd..9efc8acb92 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -5826,9 +5826,23 @@ }, "error": { "emptyUsername": "Please enter your username", - "emptyPassword": "Please enter your password" + "emptyPassword": "Please enter your password", + "oauthFailed": "OAuth login failed. Please try again." }, - "submit": "Login" + "submit": "Login", + "signInWith": "Sign in with", + "oauthPending": "Opening browser for authentication...", + "orContinueWith": "Or continue with email" + } + }, + "oauth": { + "success": { + "title": "Authentication Successful", + "message": "You can close this window and return to Stirling PDF." + }, + "error": { + "title": "Authentication Failed", + "message": "Authentication was not successful. You can close this window and try again." } }, "settings": { diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 7ddc3dbfd6..8d8bc6ffcc 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -81,6 +81,12 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "ascii" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -558,6 +564,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "chunked_transfer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901" + [[package]] name = "combine" version = "4.6.7" @@ -4215,22 +4227,29 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" name = "stirling-pdf" version = "0.1.0" dependencies = [ + "base64 0.22.1", "core-foundation 0.10.1", "core-services", "keyring", "log", + "rand 0.8.5", "reqwest 0.11.27", "serde", "serde_json", + "sha2", "tauri", "tauri-build", "tauri-plugin-fs", "tauri-plugin-http", "tauri-plugin-log", + "tauri-plugin-opener", "tauri-plugin-shell", "tauri-plugin-single-instance", "tauri-plugin-store", + "tiny_http", "tokio", + "url", + "urlencoding", ] [[package]] @@ -4646,6 +4665,28 @@ dependencies = [ "time", ] +[[package]] +name = "tauri-plugin-opener" +version = "2.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c26b72571d25dee25667940027114e60f569fc3974f8cefbe50c2cbc5fd65e3b" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation 0.3.2", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", + "url", + "windows", + "zbus", +] + [[package]] name = "tauri-plugin-shell" version = "2.3.3" @@ -4895,6 +4936,18 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny_http" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82" +dependencies = [ + "ascii", + "chunked_transfer", + "httpdate", + "log", +] + [[package]] name = "tinystr" version = "0.8.1" @@ -5273,6 +5326,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "urlpattern" version = "0.3.0" diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index 968edc53d6..c32ff2fa01 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -31,9 +31,16 @@ tauri-plugin-fs = "2.4.4" tauri-plugin-http = "2.4.4" tauri-plugin-single-instance = "2.0.1" tauri-plugin-store = "2.1.0" +tauri-plugin-opener = "2.0.0" keyring = "3.6.1" -tokio = { version = "1.0", features = ["time"] } +tokio = { version = "1.0", features = ["time", "sync"] } reqwest = { version = "0.11", features = ["json"] } +tiny_http = "0.12" +url = "2.5" +urlencoding = "2.1" +sha2 = "0.10" +base64 = "0.22" +rand = "0.8" [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.10" diff --git a/frontend/src-tauri/src/commands/auth.rs b/frontend/src-tauri/src/commands/auth.rs index 64437a1e20..30ec0d6c40 100644 --- a/frontend/src-tauri/src/commands/auth.rs +++ b/frontend/src-tauri/src/commands/auth.rs @@ -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 { 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 { + 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, + pub expires_in: Option, +} + +// 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 { + 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 { + // 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()) +} diff --git a/frontend/src-tauri/src/commands/mod.rs b/frontend/src-tauri/src/commands/mod.rs index 0f58f2a1da..ceca51dd88 100644 --- a/frontend/src-tauri/src/commands/mod.rs +++ b/frontend/src-tauri/src/commands/mod.rs @@ -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; diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index f3b8259dc4..838bdf1ec5 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -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://") { diff --git a/frontend/src/core/hooks/useAdminSettings.ts b/frontend/src/core/hooks/useAdminSettings.ts index 9054d00bac..e88e06400c 100644 --- a/frontend/src/core/hooks/useAdminSettings.ts +++ b/frontend/src/core/hooks/useAdminSettings.ts @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useCallback } from 'react'; import apiClient from '@app/services/apiClient'; import { mergePendingSettings, isFieldPending, hasPendingChanges } from '@app/utils/settingsPendingHelper'; @@ -51,7 +51,7 @@ export function useAdminSettings( const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); - const fetchSettings = async () => { + const fetchSettings = useCallback(async () => { try { setLoading(true); @@ -87,7 +87,7 @@ export function useAdminSettings( } finally { setLoading(false); } - }; + }, [sectionName]); const saveSettings = async () => { try { diff --git a/frontend/src/desktop/components/SetupWizard/LoginForm.tsx b/frontend/src/desktop/components/SetupWizard/LoginForm.tsx index 5facdf96b8..42e0a61cdc 100644 --- a/frontend/src/desktop/components/SetupWizard/LoginForm.tsx +++ b/frontend/src/desktop/components/SetupWizard/LoginForm.tsx @@ -1,6 +1,10 @@ import React, { useState } from 'react'; -import { Stack, TextInput, PasswordInput, Button, Text } from '@mantine/core'; +import { Stack, TextInput, PasswordInput, Button, Text, Divider, Group } from '@mantine/core'; import { useTranslation } from 'react-i18next'; +import { authService } from '@app/services/authService'; +import { STIRLING_SAAS_URL } from '@app/constants/connection'; +import { buildOAuthCallbackHtml } from '@app/utils/oauthCallbackHtml'; +import { BASE_PATH } from '@app/constants/app'; interface LoginFormProps { serverUrl: string; @@ -14,6 +18,7 @@ export const LoginForm: React.FC = ({ serverUrl, isSaaS = false, const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [validationError, setValidationError] = useState(null); + const [oauthLoading, setOauthLoading] = useState(false); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -33,6 +38,51 @@ export const LoginForm: React.FC = ({ serverUrl, isSaaS = false, await onLogin(username.trim(), password); }; + const handleOAuthLogin = async (provider: 'google' | 'github') => { + // Prevent concurrent OAuth attempts + if (oauthLoading || loading) { + return; + } + + try { + setOauthLoading(true); + setValidationError(null); + + // For SaaS, use configured SaaS URL; for self-hosted, derive from serverUrl + const authServerUrl = isSaaS + ? STIRLING_SAAS_URL + : serverUrl; // Self-hosted might have its own auth + + // Build callback page HTML with translations and dark mode support + const successHtml = buildOAuthCallbackHtml({ + title: t('oauth.success.title', 'Authentication Successful'), + message: t('oauth.success.message', 'You can close this window and return to Stirling PDF.'), + isError: false, + }); + + const errorHtml = buildOAuthCallbackHtml({ + title: t('oauth.error.title', 'Authentication Failed'), + message: t('oauth.error.message', 'Authentication was not successful. You can close this window and try again.'), + isError: true, + errorPlaceholder: true, // {error} will be replaced by Rust + }); + + const userInfo = await authService.loginWithOAuth(provider, authServerUrl, successHtml, errorHtml); + + // Call the onLogin callback to complete setup (username/password not needed for OAuth) + await onLogin(userInfo.username, ''); + } catch (error) { + console.error('OAuth login failed:', error); + + const errorMessage = error instanceof Error + ? error.message + : t('setup.login.error.oauthFailed', 'OAuth login failed. Please try again.'); + + setValidationError(errorMessage); + setOauthLoading(false); + } + }; + return (
@@ -40,6 +90,47 @@ export const LoginForm: React.FC = ({ serverUrl, isSaaS = false, {t('setup.login.connectingTo', 'Connecting to:')} {isSaaS ? 'stirling.com' : serverUrl} + {/* OAuth Login Buttons - Only show for SaaS */} + {isSaaS && ( + <> + + + + + + + + {oauthLoading && ( + + {t('setup.login.oauthPending', 'Opening browser for authentication...')} + + )} + + + + + )} + = ({ onComplete }) => { setLoading(true); setError(null); - await authService.login(serverConfig.url, username, password); + // Only attempt password login if a password is provided + // If password is empty, assume OAuth login already completed + const isAlreadyAuthenticated = await authService.isAuthenticated(); + if (!isAlreadyAuthenticated && password) { + await authService.login(serverConfig.url, username, password); + } + await connectionModeService.switchToSaaS(serverConfig.url); await tauriBackendService.startBackend(); onComplete(); diff --git a/frontend/src/desktop/constants/connection.ts b/frontend/src/desktop/constants/connection.ts index 7f7d538362..053bfb47b8 100644 --- a/frontend/src/desktop/constants/connection.ts +++ b/frontend/src/desktop/constants/connection.ts @@ -2,11 +2,10 @@ * Connection-related constants for desktop app */ -// SaaS server URL from environment variable (required) +// SaaS server URL from environment variable // The SaaS authentication server -// Will throw error if VITE_SAAS_SERVER_URL is not set -if (!import.meta.env.VITE_SAAS_SERVER_URL) { - throw new Error('VITE_SAAS_SERVER_URL environment variable is required'); -} +export const STIRLING_SAAS_URL: string = import.meta.env.VITE_SAAS_SERVER_URL || ''; -export const STIRLING_SAAS_URL = import.meta.env.VITE_SAAS_SERVER_URL; +// Supabase publishable key from environment variable +// Used for SaaS authentication +export const SUPABASE_KEY: string = import.meta.env.VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY || ''; diff --git a/frontend/src/desktop/services/authService.ts b/frontend/src/desktop/services/authService.ts index 65d0c4cce6..56bb7c0854 100644 --- a/frontend/src/desktop/services/authService.ts +++ b/frontend/src/desktop/services/authService.ts @@ -1,5 +1,6 @@ import { invoke } from '@tauri-apps/api/core'; import axios from 'axios'; +import { STIRLING_SAAS_URL, SUPABASE_KEY } from '@app/constants/connection'; export interface UserInfo { username: string; @@ -12,7 +13,13 @@ interface LoginResponse { email: string | null; } -export type AuthStatus = 'authenticated' | 'unauthenticated' | 'refreshing'; +interface OAuthCallbackResult { + access_token: string; + refresh_token: string | null; + expires_in: number | null; +} + +export type AuthStatus = 'authenticated' | 'unauthenticated' | 'refreshing' | 'oauth_pending'; export class AuthService { private static instance: AuthService; @@ -50,11 +57,23 @@ export class AuthService { try { console.log('Logging in to:', serverUrl); + // Validate SaaS configuration if connecting to SaaS + if (serverUrl === STIRLING_SAAS_URL) { + if (!STIRLING_SAAS_URL) { + throw new Error('VITE_SAAS_SERVER_URL is not configured'); + } + if (!SUPABASE_KEY) { + throw new Error('VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured'); + } + } + // Call Rust login command (bypasses CORS) const response = await invoke('login', { serverUrl, username, password, + supabaseKey: SUPABASE_KEY, + saasServerUrl: STIRLING_SAAS_URL, }); const { token, username: returnedUsername, email } = response; @@ -80,13 +99,7 @@ export class AuthService { } catch (error) { console.error('Login failed:', error); this.setAuthStatus('unauthenticated', null); - - // Rust commands return string errors - if (typeof error === 'string') { - throw new Error(error); - } - - throw new Error('Login failed. Please try again.'); + throw error; } } @@ -193,6 +206,89 @@ export class AuthService { this.setAuthStatus('unauthenticated', null); } } + + /** + * Start OAuth login flow by opening system browser with localhost callback + */ + async loginWithOAuth(provider: string, authServerUrl: string, successHtml: string, errorHtml: string): Promise { + try { + console.log('Starting OAuth login with provider:', provider); + this.setAuthStatus('oauth_pending', null); + + // Validate Supabase key is configured for OAuth + if (!SUPABASE_KEY) { + throw new Error('VITE_SUPABASE_PUBLISHABLE_DEFAULT_KEY is not configured'); + } + + // Call Rust command which: + // 1. Starts localhost HTTP server on random port + // 2. Opens browser to OAuth provider + // 3. Waits for callback + // 4. Returns tokens + const result = await invoke('start_oauth_login', { + provider, + authServerUrl, + supabaseKey: SUPABASE_KEY, + successHtml, + errorHtml, + }); + + console.log('OAuth authentication successful, storing tokens'); + + // Save the access token to keyring + await invoke('save_auth_token', { token: result.access_token }); + + // Fetch user info from Supabase using the access token + const userInfo = await this.fetchSupabaseUserInfo(authServerUrl, result.access_token); + + // Save user info to store + await invoke('save_user_info', { + username: userInfo.username, + email: userInfo.email || null, + }); + + this.setAuthStatus('authenticated', userInfo); + console.log('OAuth login successful'); + + return userInfo; + } catch (error) { + console.error('Failed to complete OAuth login:', error); + this.setAuthStatus('unauthenticated', null); + throw error; + } + } + + /** + * Fetch user info from Supabase using access token + */ + private async fetchSupabaseUserInfo(authServerUrl: string, accessToken: string): Promise { + try { + const userEndpoint = `${authServerUrl}/auth/v1/user`; + + const response = await axios.get(userEndpoint, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'apikey': SUPABASE_KEY, + }, + }); + + const data = response.data; + console.log('User info fetched:', data.email); + + return { + username: data.user_metadata?.full_name || data.email || 'Unknown', + email: data.email, + }; + } catch (error) { + console.error('Failed to fetch user info from Supabase:', error); + // Fallback to basic info + return { + username: 'User', + email: undefined, + }; + } + } + } export const authService = AuthService.getInstance(); diff --git a/frontend/src/desktop/utils/oauthCallbackHtml.ts b/frontend/src/desktop/utils/oauthCallbackHtml.ts new file mode 100644 index 0000000000..a2119d9981 --- /dev/null +++ b/frontend/src/desktop/utils/oauthCallbackHtml.ts @@ -0,0 +1,154 @@ +/** + * Builds HTML for OAuth callback pages with i18n and dark mode support + */ + +interface OAuthCallbackHtmlOptions { + title: string; + message: string; + isError?: boolean; + errorPlaceholder?: boolean; +} + +/** + * Generates OAuth callback HTML with automatic dark mode support + */ +export function buildOAuthCallbackHtml({ + title, + message, + isError = false, + errorPlaceholder = false, +}: OAuthCallbackHtmlOptions): string { + const iconColor = isError ? '#d32f2f' : '#2e7d32'; + const iconColorDark = isError ? '#ef5350' : '#66bb6a'; + const icon = isError ? '✗' : '✓'; + + return ` + + + + + ${title} + + + +
+
${icon}
+

${title}

+

${message}

+ ${errorPlaceholder ? '
{error}
' : ''} +
+ +`; +}