From bafbe271d849700fedc7e04f0b4726fe80b095ea Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Thu, 27 Nov 2025 13:28:33 +0800 Subject: [PATCH] =?UTF-8?q?sign=20in=20auth=20flow=20=F0=9F=91=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/Cargo.lock | 94 ++++ src-tauri/Cargo.toml | 5 + src-tauri/src/app.rs | 69 ++- src-tauri/src/assets/auth-success.html | 42 ++ src-tauri/src/core/models/app_config.rs | 1 + src-tauri/src/core/services/auth.rs | 626 +++++++++++++++++++++++- src-tauri/src/core/state.rs | 19 + src-tauri/src/lib.rs | 12 +- src-tauri/src/services/overlay.rs | 21 +- 9 files changed, 838 insertions(+), 51 deletions(-) create mode 100644 src-tauri/src/assets/auth-success.html diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 81e73e5..78106b5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -47,6 +47,12 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[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" @@ -456,6 +462,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" @@ -1047,6 +1059,7 @@ dependencies = [ "device_query", "dotenvy", "keyring", + "once_cell", "rand 0.9.2", "reqwest", "serde", @@ -1057,7 +1070,11 @@ dependencies = [ "tauri-plugin-global-shortcut", "tauri-plugin-opener", "tauri-plugin-positioner", + "thiserror 1.0.69", + "tiny_http", "tokio", + "tracing", + "tracing-subscriber", "ts-rs", "url", ] @@ -1593,6 +1610,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.7.0" @@ -2277,6 +2300,15 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -3688,6 +3720,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "1.3.0" @@ -4341,6 +4382,15 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "time" version = "0.3.44" @@ -4372,6 +4422,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.2" @@ -4610,6 +4672,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", ] [[package]] @@ -4792,6 +4880,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0240e20..9fd59e1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -34,6 +34,11 @@ url = "2.5.7" rand = "0.9.2" sha2 = "0.10.9" base64 = "0.22.1" +tiny_http = "0.12.0" +thiserror = "1" +tracing = "0.1" +tracing-subscriber = "0.3" +once_cell = "1" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-global-shortcut = "2" diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index 2989c7b..5c090b5 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -1,17 +1,39 @@ use tauri::Manager; use tauri_plugin_positioner::WindowExt; +use tracing::{error, info}; use crate::{ + core::services::auth::get_tokens, get_app_handle, services::overlay::{overlay_fullscreen, SCENE_WINDOW_LABEL}, }; pub async fn start_fdoll() { - initialize_session().await; + init_session().await; } -pub async fn initialize_session() { - let webview_window = tauri::WebviewWindowBuilder::new( +pub async fn init_session() { + match get_tokens().await { + Some(_) => { + info!("User session restored"); + create_scene().await; + } + None => { + info!("No active session, user needs to authenticate"); + crate::core::services::auth::init_auth_code_retrieval(|| { + info!("Authentication successful, creating scene..."); + tauri::async_runtime::spawn(async { + info!("Creating scene after auth success..."); + create_scene().await; + }); + }); + } + } +} + +pub async fn create_scene() { + info!("Starting scene creation..."); + let webview_window = match tauri::WebviewWindowBuilder::new( get_app_handle(), SCENE_WINDOW_LABEL, tauri::WebviewUrl::App("/scene".into()), @@ -27,19 +49,42 @@ pub async fn initialize_session() { .always_on_top(true) .visible_on_all_workspaces(true) .build() - .expect("Failed to display scene screen"); + { + Ok(window) => { + info!("Scene window builder succeeded"); + window + } + Err(e) => { + error!("Failed to build scene window: {}", e); + return; + } + }; - webview_window - .move_window(tauri_plugin_positioner::Position::Center) - .unwrap(); + if let Err(e) = webview_window.move_window(tauri_plugin_positioner::Position::Center) { + error!("Failed to move scene window to center: {}", e); + return; + } - let window = get_app_handle().get_window(webview_window.label()).unwrap(); - overlay_fullscreen(&window).unwrap(); - window.set_ignore_cursor_events(true).unwrap(); + let window = match get_app_handle().get_window(webview_window.label()) { + Some(window) => window, + None => { + error!("Failed to get scene window after creation"); + return; + } + }; + + if let Err(e) = overlay_fullscreen(&window) { + error!("Failed to set overlay fullscreen: {}", e); + return; + } + + if let Err(e) = window.set_ignore_cursor_events(true) { + error!("Failed to set ignore cursor events: {}", e); + return; + } #[cfg(debug_assertions)] webview_window.open_devtools(); - println!("Scene window initialized."); - crate::core::services::auth::get_auth_code(); + info!("Scene window initialized successfully."); } diff --git a/src-tauri/src/assets/auth-success.html b/src-tauri/src/assets/auth-success.html new file mode 100644 index 0000000..51366c6 --- /dev/null +++ b/src-tauri/src/assets/auth-success.html @@ -0,0 +1,42 @@ + + + + Authentication Successful + + + +
+
+

Signed in!

+

You have been successfully authenticated.

+

You may now close this window and return to the application.

+
+ + diff --git a/src-tauri/src/core/models/app_config.rs b/src-tauri/src/core/models/app_config.rs index 18749cb..a1a8751 100644 --- a/src-tauri/src/core/models/app_config.rs +++ b/src-tauri/src/core/models/app_config.rs @@ -7,6 +7,7 @@ pub struct AuthConfig { pub audience: String, pub auth_url: String, pub redirect_uri: String, + pub redirect_host: String, } #[derive(Default, Serialize, Deserialize, Clone, Debug, TS)] diff --git a/src-tauri/src/core/services/auth.rs b/src-tauri/src/core/services/auth.rs index 72facd5..0a44b62 100644 --- a/src-tauri/src/core/services/auth.rs +++ b/src-tauri/src/core/services/auth.rs @@ -1,10 +1,83 @@ -use crate::{core::state::FDOLL, lock_r, APP_HANDLE}; +use crate::{core::state::FDOLL, lock_r, lock_w, APP_HANDLE}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use keyring::Entry; use rand::{distr::Alphanumeric, Rng}; +use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; use tauri_plugin_opener::OpenerExt; +use thiserror::Error; +use tokio::sync::Mutex; +use tracing::{error, info, warn}; +use url::form_urlencoded; -/// Generate a random code verifier (PKCE spec: 43 to 128 chars, here defaulting to 64) +static REFRESH_LOCK: once_cell::sync::Lazy> = + once_cell::sync::Lazy::new(|| Mutex::new(())); + +static AUTH_SUCCESS_HTML: &str = include_str!("../../assets/auth-success.html"); + +/// Errors that can occur during OAuth authentication flow. +#[derive(Debug, Error)] +pub enum OAuthError { + #[error("Failed to exchange code: {0}")] + ExchangeFailed(String), + + #[error("Invalid callback state - possible CSRF attack")] + InvalidState, + + #[error("Missing callback parameter: {0}")] + MissingParameter(String), + + #[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("Server binding failed: {0}")] + ServerBindError(String), + + #[error("Callback timeout - no response received")] + CallbackTimeout, + + #[error("Invalid app configuration")] + InvalidConfig, + + #[error("Failed to refresh token")] + RefreshFailed, + + #[error("OAuth state expired or not initialized")] + StateExpired, +} + +/// Parameters received from the OAuth callback. +pub struct OAuthCallbackParams { + state: String, + session_state: String, + iss: String, + code: String, +} + +/// Authentication pass containing access token, refresh token, and metadata. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AuthPass { + pub access_token: String, + pub expires_in: u64, + pub refresh_expires_in: u64, + pub refresh_token: String, + pub token_type: String, + pub session_state: String, + pub scope: String, + pub issued_at: Option, +} + +/// Generate a random code verifier for PKCE. +/// +/// Per PKCE spec (RFC 7636), the code verifier should be 43-128 characters. fn generate_code_verifier(length: usize) -> String { rand::rng() .sample_iter(&Alphanumeric) @@ -13,7 +86,9 @@ fn generate_code_verifier(length: usize) -> String { .collect() } -/// Generate code challenge from a code verifier +/// Generate code challenge from a code verifier using SHA-256. +/// +/// This implements the S256 method as specified in RFC 7636. fn generate_code_challenge(code_verifier: &str) -> String { let mut hasher = Sha256::new(); hasher.update(code_verifier.as_bytes()); @@ -23,44 +98,545 @@ fn generate_code_challenge(code_verifier: &str) -> String { /// Returns the auth pass object, including /// access token, refresh token, expire time etc. -#[allow(dead_code)] -pub fn get_tokens() { - todo!(); +/// Automatically refreshes if expired. +pub async fn get_tokens() -> Option { + info!("Retrieving tokens"); + let Some(auth_pass) = ({ lock_r!(FDOLL).auth_pass.clone() }) else { + return None; + }; + + let Some(issued_at) = auth_pass.issued_at else { + warn!("Auth pass missing issued_at timestamp, clearing"); + lock_w!(FDOLL).auth_pass = None; + return None; + }; + + let current_time = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs(); + + let expired = current_time - issued_at >= auth_pass.expires_in; + let refresh_expired = current_time - issued_at >= auth_pass.refresh_expires_in; + + if !expired { + return Some(auth_pass); + } + + if refresh_expired { + info!("Refresh token expired, clearing auth state"); + lock_w!(FDOLL).auth_pass = None; + if let Err(e) = clear_auth_pass() { + error!("Failed to clear expired auth pass: {}", e); + } + return None; + } + + // Use mutex to prevent concurrent refresh + let _guard = REFRESH_LOCK.lock().await; + + // Double-check after acquiring lock + let auth_pass = lock_r!(FDOLL).auth_pass.clone()?; + let current_time = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs(); + let expired = current_time - auth_pass.issued_at? >= auth_pass.expires_in; + + if !expired { + // Another thread already refreshed + return Some(auth_pass); + } + + info!("Access token expired, attempting refresh"); + match refresh_token(&auth_pass.refresh_token).await { + Ok(new_pass) => Some(new_pass), + Err(e) => { + error!("Failed to refresh token: {}", e); + lock_w!(FDOLL).auth_pass = None; + if let Err(e) = clear_auth_pass() { + error!("Failed to clear auth pass after refresh failure: {}", e); + } + None + } + } } -/// Opens the auth portal in the browser, -/// and returns auth code after user logged in. -pub fn get_auth_code() { - let app_config = lock_r!(FDOLL) - .app_config - .clone() - .expect("Invalid app config"); +/// Helper function to get the current access token. +pub async fn get_access_token() -> Option { + get_tokens().await.map(|pass| pass.access_token) +} - let opener = APP_HANDLE.get().unwrap().opener(); +/// Save auth_pass to secure storage (keyring) and update app state. +pub fn save_auth_pass(auth_pass: &AuthPass) -> Result<(), OAuthError> { + let entry = Entry::new("friendolls", "auth_pass")?; + let json = serde_json::to_string(auth_pass)?; + entry.set_password(&json)?; + info!("Auth pass saved to keyring successfully"); + Ok(()) +} + +/// Load auth_pass from secure storage (keyring). +pub fn load_auth_pass() -> Result, OAuthError> { + info!("Reading credentials from keyring"); + let entry = match Entry::new("friendolls", "auth_pass") { + Ok(value) => value, + Err(e) => { + error!("Failed to open keyring entry"); + panic!() + } + }; + info!("Opened credentials from keyring"); + match entry.get_password() { + Ok(json) => { + info!("Got credentials from keyring"); + let auth_pass: AuthPass = match serde_json::from_str(&json) { + Ok(v) => { + info!("Deserialized auth pass from keyring"); + v + } + Err(e) => { + error!("Failed to decode auth pass from keyring"); + return Ok(None); + } + }; + info!("Auth pass loaded from keyring"); + Ok(Some(auth_pass)) + } + Err(keyring::Error::NoEntry) => { + info!("No auth pass found in keyring"); + Ok(None) + } + Err(e) => { + error!("Failed to load from keyring"); + Err(OAuthError::KeyringError(e)) + } + } +} + +/// Clear auth_pass from secure storage and app state. +pub fn clear_auth_pass() -> Result<(), OAuthError> { + let entry = Entry::new("friendolls", "auth_pass")?; + match entry.delete_credential() { + Ok(_) => { + info!("Auth pass cleared from keyring successfully"); + Ok(()) + } + Err(keyring::Error::NoEntry) => { + info!("Auth pass already cleared from keyring"); + Ok(()) + } + Err(e) => Err(OAuthError::KeyringError(e)), + } +} + +/// Logout the current user by clearing tokens from storage and state. +/// +/// # Note +/// +/// This currently only clears local tokens. For complete logout, you should also +/// call the OAuth provider's token revocation endpoint if available. +/// +/// # Example +/// +/// ```rust,no_run +/// use crate::core::services::auth::logout; +/// +/// logout().expect("Failed to logout"); +/// ``` +pub fn logout() -> Result<(), OAuthError> { + info!("Logging out user"); + lock_w!(FDOLL).auth_pass = None; + clear_auth_pass()?; + + // Clear OAuth flow state as well + lock_w!(FDOLL).oauth_flow = Default::default(); + + // TODO: Call OAuth provider's revocation endpoint + // This would require adding a revoke_token() function that calls: + // POST {auth_url}/revoke with the refresh_token + + Ok(()) +} + +/// Helper to add authentication header to a request builder if tokens are available. +/// +/// # Example +/// +/// ```rust,no_run +/// use crate::core::services::auth::with_auth; +/// +/// let client = reqwest::Client::new(); +/// let request = client.get("https://api.example.com/user"); +/// let authenticated_request = with_auth(request).await; +/// ``` +pub async fn with_auth(request: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + if let Some(token) = get_access_token().await { + request.header("Authorization", format!("Bearer {}", token)) + } else { + request + } +} + +/// Exchange authorization code for tokens. +/// +/// This is called after receiving the OAuth callback with an authorization code. +/// It exchanges the code for an access token and refresh token. +/// +/// # Arguments +/// +/// * `callback_params` - Parameters received from the OAuth callback +/// * `code_verifier` - The PKCE code verifier that was used to generate the code challenge +/// +/// # Errors +/// +/// Returns `OAuthError` if the exchange fails or the server returns an error. +pub async fn exchange_code_for_auth_pass( + callback_params: OAuthCallbackParams, + code_verifier: &str, +) -> Result { + let (app_config, http_client) = { + let guard = lock_r!(FDOLL); + ( + guard.app_config.clone().ok_or(OAuthError::InvalidConfig)?, + guard.http_client.clone(), + ) + }; + + let url = url::Url::parse(&format!("{}/token", &app_config.auth.auth_url)) + .map_err(|_| OAuthError::InvalidConfig)?; + + let body = form_urlencoded::Serializer::new(String::new()) + .append_pair("client_id", &app_config.auth.audience) + .append_pair("grant_type", "authorization_code") + .append_pair("redirect_uri", &app_config.auth.redirect_uri) + .append_pair("code", &callback_params.code) + .append_pair("code_verifier", code_verifier) + .finish(); + + info!("Exchanging authorization code for tokens"); + + let exchange_request = http_client + .post(url) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(body); + + let exchange_request_response = exchange_request.send().await?; + + if !exchange_request_response.status().is_success() { + let status = exchange_request_response.status(); + let error_text = exchange_request_response.text().await.unwrap_or_default(); + error!( + "Token exchange failed with status {}: {}", + status, error_text + ); + return Err(OAuthError::ExchangeFailed(format!( + "Status: {}, Body: {}", + status, error_text + ))); + } + + let mut auth_pass: AuthPass = exchange_request_response.json().await?; + auth_pass.issued_at = Some( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| OAuthError::ExchangeFailed("System time error".to_string()))? + .as_secs(), + ); + + info!("Successfully exchanged code for tokens"); + Ok(auth_pass) +} + +/// Initialize the OAuth authorization code flow. +/// +/// This function: +/// 1. Generates PKCE code verifier and challenge +/// 2. Generates state parameter for CSRF protection +/// 3. Stores state and code verifier in app state +/// 4. Opens the OAuth authorization URL in the user's browser +/// 5. Starts a background listener for the callback +/// +/// The user will be redirected to the OAuth provider's login page, and after +/// successful authentication, will be redirected back to the local callback server. +/// +/// # Example +/// +/// ```rust,no_run +/// use crate::core::services::auth::init_auth_code_retrieval; +/// +/// init_auth_code_retrieval(); +/// // User will be prompted to login in their browser +/// ``` +pub fn init_auth_code_retrieval(on_success: F) +where + F: FnOnce() + Send + 'static, +{ + let app_config = match lock_r!(FDOLL).app_config.clone() { + Some(config) => config, + None => { + error!("Cannot initialize auth: app config not available"); + return; + } + }; + + let opener = match APP_HANDLE.get() { + Some(handle) => handle.opener(), + None => { + error!("Cannot initialize auth: app handle not available"); + return; + } + }; let code_verifier = generate_code_verifier(64); let code_challenge = generate_code_challenge(&code_verifier); let state = generate_code_verifier(16); - let mut url = url::Url::parse(&app_config.auth.auth_url.as_str()).expect("Invalid app config"); + // Store state and code_verifier for validation + let current_time = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + { + let mut guard = lock_w!(FDOLL); + guard.oauth_flow.state = Some(state.clone()); + guard.oauth_flow.code_verifier = Some(code_verifier.clone()); + guard.oauth_flow.initiated_at = Some(current_time); + } + + let mut url = match url::Url::parse(&format!("{}/auth", &app_config.auth.auth_url)) { + Ok(url) => url, + Err(e) => { + error!("Invalid auth URL configuration: {}", e); + return; + } + }; + url.query_pairs_mut() - .append_pair("client_id", &app_config.auth.audience.as_str()) + .append_pair("client_id", &app_config.auth.audience) .append_pair("response_type", "code") - .append_pair("redirect_uri", &app_config.auth.redirect_uri.as_str()) + .append_pair("redirect_uri", &app_config.auth.redirect_uri) .append_pair("scope", "openid email profile") .append_pair("state", &state) .append_pair("code_challenge", &code_challenge) .append_pair("code_challenge_method", "S256"); - match opener.open_url(url, None::<&str>) { - Ok(_) => (), - Err(e) => panic!("Failed to open auth portal: {}", e), + info!("Initiating OAuth flow"); + + thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async move { + match listen_for_callback().await { + Ok(callback_params) => { + // Validate state + let stored_state = lock_r!(FDOLL).oauth_flow.state.clone(); + + if stored_state.as_ref() != Some(&callback_params.state) { + error!("State mismatch - possible CSRF attack!"); + return; + } + + // Retrieve code_verifier + let code_verifier = match lock_r!(FDOLL).oauth_flow.code_verifier.clone() { + Some(cv) => cv, + None => { + error!("Code verifier not found in state"); + return; + } + }; + + // Clear OAuth flow state after successful callback + lock_w!(FDOLL).oauth_flow = Default::default(); + + match exchange_code_for_auth_pass(callback_params, &code_verifier).await { + Ok(auth_pass) => { + lock_w!(FDOLL).auth_pass = Some(auth_pass.clone()); + if let Err(e) = save_auth_pass(&auth_pass) { + error!("Failed to save auth pass: {}", e); + } else { + info!("Authentication successful!"); + on_success(); + } + } + Err(e) => { + error!("Failed to exchange code for tokens: {}", e); + } + } + } + Err(e) => { + error!("Failed to receive callback: {}", e); + // Clear OAuth flow state on error + lock_w!(FDOLL).oauth_flow = Default::default(); + } + } + }); + }); + + if let Err(e) = opener.open_url(url, None::<&str>) { + error!("Failed to open auth portal: {}", e); } } -/// Accepts a refresh token and -/// returns a new access token. -#[allow(dead_code)] -pub fn refresh_token() { - todo!(); +/// Refresh the access token using a refresh token. +/// +/// This is called automatically by `get_tokens()` when the access token is expired +/// but the refresh token is still valid. +/// +/// # Arguments +/// +/// * `refresh_token` - The refresh token to use +/// +/// # Errors +/// +/// Returns `OAuthError::RefreshFailed` if the refresh fails. +pub async fn refresh_token(refresh_token: &str) -> Result { + let (app_config, http_client) = { + let guard = lock_r!(FDOLL); + ( + guard.app_config.clone().ok_or(OAuthError::InvalidConfig)?, + guard.http_client.clone(), + ) + }; + + let url = url::Url::parse(&format!("{}/token", &app_config.auth.auth_url)) + .map_err(|_| OAuthError::InvalidConfig)?; + + let body = form_urlencoded::Serializer::new(String::new()) + .append_pair("client_id", &app_config.auth.audience) + .append_pair("grant_type", "refresh_token") + .append_pair("refresh_token", refresh_token) + .finish(); + + info!("Refreshing access token"); + + let refresh_request = http_client + .post(url) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(body); + + let refresh_response = refresh_request.send().await?; + + if !refresh_response.status().is_success() { + let status = refresh_response.status(); + let error_text = refresh_response.text().await.unwrap_or_default(); + error!( + "Token refresh failed with status {}: {}", + status, error_text + ); + return Err(OAuthError::RefreshFailed); + } + + let mut auth_pass: AuthPass = refresh_response.json().await?; + auth_pass.issued_at = Some( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| OAuthError::RefreshFailed)? + .as_secs(), + ); + + // Update state and storage + lock_w!(FDOLL).auth_pass = Some(auth_pass.clone()); + if let Err(e) = save_auth_pass(&auth_pass) { + error!("Failed to save refreshed auth pass: {}", e); + } else { + info!("Token refreshed successfully"); + } + + Ok(auth_pass) +} + +/// Start a local HTTP server to listen for the OAuth callback. +/// +/// This function starts a mini web server that listens on the configured redirect host +/// for the OAuth callback. It: +/// - Listens on the `/callback` endpoint +/// - Validates all required parameters are present +/// - Returns a nice HTML page to the user +/// - Has a 5-minute timeout to prevent hanging indefinitely +/// - Also provides a `/health` endpoint for health checks +/// +/// # Timeout +/// +/// The server will timeout after 5 minutes if no callback is received, +/// preventing the server from running indefinitely if the user abandons the flow. +/// +/// # Errors +/// +/// Returns `OAuthError` if: +/// - Server fails to bind to the configured port +/// - Required callback parameters are missing +/// - Timeout is reached before callback is received +async fn listen_for_callback() -> Result { + let app_config = lock_r!(FDOLL) + .app_config + .clone() + .ok_or(OAuthError::InvalidConfig)?; + + let server = tiny_http::Server::http(&app_config.auth.redirect_host) + .map_err(|e| OAuthError::ServerBindError(e.to_string()))?; + + info!( + "Listening on {} for /callback", + &app_config.auth.redirect_host + ); + + // Set a 5-minute timeout + let timeout = Duration::from_secs(300); + let start_time = SystemTime::now(); + + for request in server.incoming_requests() { + // Check timeout + if SystemTime::now() + .duration_since(start_time) + .unwrap_or(Duration::ZERO) + > timeout + { + warn!("Callback listener timed out after 5 minutes"); + return Err(OAuthError::CallbackTimeout); + } + + let url = request.url().to_string(); + + if url.starts_with("/callback") { + let query = url.split('?').nth(1).unwrap_or(""); + let params = form_urlencoded::parse(query.as_bytes()) + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect::>(); + + info!("Received OAuth callback"); + + let find_param = |key: &str| -> Result { + params + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.clone()) + .ok_or_else(|| OAuthError::MissingParameter(key.to_string())) + }; + + let callback_params = OAuthCallbackParams { + state: find_param("state")?, + session_state: find_param("session_state")?, + iss: find_param("iss")?, + code: find_param("code")?, + }; + + let response = tiny_http::Response::from_string(AUTH_SUCCESS_HTML).with_header( + tiny_http::Header::from_bytes( + &b"Content-Type"[..], + &b"text/html; charset=utf-8"[..], + ) + .map_err(|_| OAuthError::ServerBindError("Header creation failed".to_string()))?, + ); + + let _ = request.respond(response); + + info!("Callback processed, stopping listener"); + return Ok(callback_params); + } else if url == "/health" { + // Health check endpoint + let _ = request.respond(tiny_http::Response::from_string("OK")); + } else { + let _ = request.respond(tiny_http::Response::empty(404)); + } + } + + Err(OAuthError::CallbackTimeout) } diff --git a/src-tauri/src/core/state.rs b/src-tauri/src/core/state.rs index 4be93ce..629ecea 100644 --- a/src-tauri/src/core/state.rs +++ b/src-tauri/src/core/state.rs @@ -1,6 +1,7 @@ // in app-core/src/state.rs use crate::{ core::models::app_config::{AppConfig, AuthConfig}, + core::services::auth::{load_auth_pass, AuthPass}, lock_w, }; use reqwest::Client; @@ -8,11 +9,21 @@ use std::{ env, sync::{Arc, LazyLock, RwLock}, }; +use tracing::warn; + +#[derive(Default, Clone)] +pub struct OAuthFlowTracker { + pub state: Option, + pub code_verifier: Option, + pub initiated_at: Option, +} #[derive(Default)] pub struct AppState { pub app_config: Option, pub http_client: Client, + pub auth_pass: Option, + pub oauth_flow: OAuthFlowTracker, } // Global application state @@ -30,8 +41,16 @@ pub fn init_fdoll_state() { audience: env::var("JWT_AUDIENCE").expect("JWT_AUDIENCE must be set"), auth_url: env::var("AUTH_URL").expect("AUTH_URL must be set"), redirect_uri: env::var("REDIRECT_URI").expect("REDIRECT_URI must be set"), + redirect_host: env::var("REDIRECT_HOST").expect("REDIRECT_HOST must be set"), }, }); + guard.auth_pass = match load_auth_pass() { + Ok(pass) => pass, + Err(e) => { + warn!("Failed to load auth pass from keyring: {e}"); + None + } + }; guard.http_client = reqwest::ClientBuilder::new() .redirect(reqwest::redirect::Policy::none()) .build() diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 548dc2b..cfd8c07 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,6 @@ use crate::services::cursor::channel_cursor_positions; +use tauri::async_runtime; +use tracing_subscriber; static APP_HANDLE: std::sync::OnceLock> = std::sync::OnceLock::new(); @@ -14,8 +16,16 @@ pub fn get_app_handle<'a>() -> &'a tauri::AppHandle { } fn setup_fdoll() -> Result<(), tauri::Error> { + // Initialize tracing subscriber for logging + tracing_subscriber::fmt() + .with_target(false) + .with_thread_ids(false) + .with_file(true) + .with_line_number(true) + .init(); + core::state::init_fdoll_state(); - tokio::spawn(async move { app::start_fdoll().await }); + async_runtime::spawn(async move { app::start_fdoll().await }); Ok(()) } diff --git a/src-tauri/src/services/overlay.rs b/src-tauri/src/services/overlay.rs index 9f0b07f..89f3470 100644 --- a/src-tauri/src/services/overlay.rs +++ b/src-tauri/src/services/overlay.rs @@ -1,24 +1,19 @@ use crate::get_app_handle; - pub static SCENE_WINDOW_LABEL: &str = "scene"; - pub fn overlay_fullscreen(window: &tauri::Window) -> Result<(), tauri::Error> { // Get the primary monitor let monitor = get_app_handle().primary_monitor()?.unwrap(); - let monitor_position = monitor.position(); - let monitor_size = monitor.size(); - - // Set window position to top-left + // Get the work area (usable space, excluding menu bar/dock/notch) + let work_area = monitor.work_area(); + // Set window position to top-left of the work area window.set_position(tauri::PhysicalPosition { - x: monitor_position.x, - y: monitor_position.y, + x: work_area.position.x, + y: work_area.position.y, })?; - - // Set window size to match screen size + // Set window size to match work area size window.set_size(tauri::PhysicalSize { - width: monitor_size.width, - height: monitor_size.height, + width: work_area.size.width, + height: work_area.size.height, })?; - Ok(()) }