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