diff --git a/src-tauri/src/assets/auth-cancelled.html b/src-tauri/src/assets/auth-cancelled.html new file mode 100644 index 0000000..0127441 --- /dev/null +++ b/src-tauri/src/assets/auth-cancelled.html @@ -0,0 +1,51 @@ + + + + + Friendolls Sign-in Cancelled + + + +
+

Sign-in cancelled

+

You can close this tab and return to Friendolls to try again.

+
+ + diff --git a/src-tauri/src/assets/auth-failed.html b/src-tauri/src/assets/auth-failed.html new file mode 100644 index 0000000..be6c54d --- /dev/null +++ b/src-tauri/src/assets/auth-failed.html @@ -0,0 +1,51 @@ + + + + + Friendolls Sign-in Failed + + + +
+

Sign-in failed

+

Friendolls could not complete the browser handshake. Return to the app and try again.

+
+ + diff --git a/src-tauri/src/assets/auth-success.html b/src-tauri/src/assets/auth-success.html new file mode 100644 index 0000000..da20e9e --- /dev/null +++ b/src-tauri/src/assets/auth-success.html @@ -0,0 +1,51 @@ + + + + + Friendolls Sign-in Complete + + + +
+

Signed in

+

You can close this browser tab and return to Friendolls.

+
+ + diff --git a/src-tauri/src/commands/auth.rs b/src-tauri/src/commands/auth.rs index db286c3..b434e4f 100644 --- a/src-tauri/src/commands/auth.rs +++ b/src-tauri/src/commands/auth.rs @@ -8,45 +8,16 @@ pub async fn logout_and_restart() -> Result<(), String> { #[tauri::command] #[specta::specta] -pub async fn login(email: String, password: String) -> Result<(), String> { - auth::login_and_init_session(&email, &password) +pub async fn start_google_auth() -> Result<(), String> { + auth::start_browser_login("google") .await .map_err(|e| e.to_string()) } #[tauri::command] #[specta::specta] -pub async fn register( - email: String, - password: String, - name: Option, - username: Option, -) -> Result { - auth::register( - &email, - &password, - name.as_deref(), - username.as_deref(), - ) - .await - .map_err(|e| e.to_string()) -} - -#[tauri::command] -#[specta::specta] -pub async fn change_password( - current_password: String, - new_password: String, -) -> Result<(), String> { - auth::change_password(¤t_password, &new_password) - .await - .map_err(|e| e.to_string()) -} - -#[tauri::command] -#[specta::specta] -pub async fn reset_password(old_password: String, new_password: String) -> Result<(), String> { - auth::reset_password(&old_password, &new_password) +pub async fn start_discord_auth() -> Result<(), String> { + auth::start_browser_login("discord") .await .map_err(|e| e.to_string()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c17dbc8..16a65ea 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -10,7 +10,7 @@ use commands::app_state::{ get_active_doll_sprite_base64, get_app_data, get_friend_active_doll_sprites_base64, refresh_app_data, }; -use commands::auth::{change_password, login, logout_and_restart, register, reset_password}; +use commands::auth::{logout_and_restart, start_discord_auth, start_google_auth}; use commands::config::{get_client_config, open_client_config, save_client_config}; use commands::dolls::{ create_doll, delete_doll, get_doll, get_dolls, remove_active_doll, set_active_doll, update_doll, @@ -27,9 +27,9 @@ use tauri::async_runtime; use tauri_specta::{collect_commands, collect_events, Builder as SpectaBuilder, ErrorHandlingMode}; use crate::services::app_events::{ - ActiveDollSpriteChanged, AppDataRefreshed, CreateDoll, CursorMoved, EditDoll, - FriendActiveDollChanged, FriendActiveDollSpritesUpdated, FriendCursorPositionsUpdated, - FriendDisconnected, + ActiveDollSpriteChanged, AppDataRefreshed, AuthFlowUpdated, CreateDoll, CursorMoved, + EditDoll, FriendActiveDollChanged, FriendActiveDollSpritesUpdated, + FriendCursorPositionsUpdated, FriendDisconnected, FriendRequestAccepted, FriendRequestDenied, FriendRequestReceived, FriendUserStatusChanged, InteractionDeliveryFailed, InteractionReceived, SceneInteractiveChanged, SetInteractionOverlay, Unfriended, UserStatusChanged, @@ -99,10 +99,8 @@ pub fn run() { get_scene_interactive, set_scene_interactive, set_pet_menu_state, - login, - register, - change_password, - reset_password, + start_google_auth, + start_discord_auth, logout_and_restart, send_interaction_cmd, get_modules @@ -126,7 +124,8 @@ pub fn run() { FriendRequestReceived, FriendRequestAccepted, FriendRequestDenied, - Unfriended + Unfriended, + AuthFlowUpdated ]); #[cfg(debug_assertions)] diff --git a/src-tauri/src/services/app_events.rs b/src-tauri/src/services/app_events.rs index 36910ff..eb18bcd 100644 --- a/src-tauri/src/services/app_events.rs +++ b/src-tauri/src/services/app_events.rs @@ -18,6 +18,23 @@ use crate::{ }, }; +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "snake_case")] +pub enum AuthFlowStatus { + Started, + Succeeded, + Failed, + Cancelled, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct AuthFlowUpdatedPayload { + pub provider: String, + pub status: AuthFlowStatus, + pub message: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] #[tauri_specta(event_name = "cursor-position")] pub struct CursorMoved(pub CursorPositions); @@ -93,3 +110,7 @@ pub struct FriendRequestDenied(pub FriendRequestDeniedPayload); #[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] #[tauri_specta(event_name = "unfriended")] pub struct Unfriended(pub UnfriendedPayload); + +#[derive(Debug, Clone, Serialize, Deserialize, Type, Event)] +#[tauri_specta(event_name = "auth-flow-updated")] +pub struct AuthFlowUpdated(pub AuthFlowUpdatedPayload); diff --git a/src-tauri/src/services/auth/api.rs b/src-tauri/src/services/auth/api.rs index 750644c..7705c78 100644 --- a/src-tauri/src/services/auth/api.rs +++ b/src-tauri/src/services/auth/api.rs @@ -6,48 +6,46 @@ use crate::{lock_r, lock_w, state::FDOLL}; use super::storage::{build_auth_pass, save_auth_pass, AuthError, AuthPass}; #[derive(Debug, Deserialize)] -struct LoginResponse { +pub struct StartSsoResponse { + pub state: String, + #[serde(rename = "authorizeUrl")] + pub authorize_url: Option, +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { #[serde(rename = "accessToken")] access_token: String, #[serde(rename = "expiresIn")] expires_in: u64, -} - -#[derive(Debug, Deserialize)] -struct RegisterResponse { - id: String, + #[serde(rename = "refreshToken")] + refresh_token: String, + #[serde(rename = "refreshExpiresIn")] + refresh_expires_in: u64, } #[derive(Debug, Serialize)] -struct LoginRequest<'a> { - email: &'a str, - password: &'a str, +struct StartSsoRequest<'a> { + provider: &'a str, + #[serde(rename = "redirectUri")] + redirect_uri: &'a str, } #[derive(Debug, Serialize)] -struct RegisterRequest<'a> { - email: &'a str, - password: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - name: Option<&'a str>, - #[serde(skip_serializing_if = "Option::is_none")] - username: Option<&'a str>, +struct ExchangeSsoCodeRequest<'a> { + code: &'a str, } #[derive(Debug, Serialize)] -struct ChangePasswordRequest<'a> { - #[serde(rename = "currentPassword")] - current_password: &'a str, - #[serde(rename = "newPassword")] - new_password: &'a str, +struct RefreshTokenRequest<'a> { + #[serde(rename = "refreshToken")] + refresh_token: &'a str, } #[derive(Debug, Serialize)] -struct ResetPasswordRequest<'a> { - #[serde(rename = "oldPassword")] - old_password: &'a str, - #[serde(rename = "newPassword")] - new_password: &'a str, +struct LogoutRequest<'a> { + #[serde(rename = "refreshToken")] + refresh_token: &'a str, } fn auth_http_context() -> Result<(String, reqwest::Client), AuthError> { @@ -87,96 +85,72 @@ pub async fn with_auth(request: reqwest::RequestBuilder) -> reqwest::RequestBuil } } -pub async fn login(email: &str, password: &str) -> Result { +pub async fn start_sso(provider: &str, redirect_uri: &str) -> Result { let (base_url, http_client) = auth_http_context()?; let response = http_client - .post(format!("{}/auth/login", base_url)) - .json(&LoginRequest { email, password }) - .send() - .await?; - - let login_response: LoginResponse = ensure_success(response).await?.json().await?; - let auth_pass = build_auth_pass(login_response.access_token, login_response.expires_in)?; - lock_w!(FDOLL).auth.auth_pass = Some(auth_pass.clone()); - save_auth_pass(&auth_pass)?; - Ok(auth_pass) -} - -pub async fn register( - email: &str, - password: &str, - name: Option<&str>, - username: Option<&str>, -) -> Result { - let (base_url, http_client) = auth_http_context()?; - let response = http_client - .post(format!("{}/auth/register", base_url)) - .json(&RegisterRequest { - email, - password, - name, - username, + .post(format!("{}/auth/sso/start", base_url)) + .json(&StartSsoRequest { + provider, + redirect_uri, }) .send() .await?; - let register_response: RegisterResponse = ensure_success(response).await?.json().await?; - Ok(register_response.id) + ensure_success(response).await?.json().await.map_err(AuthError::from) } -pub async fn change_password( - current_password: &str, - new_password: &str, -) -> Result<(), AuthError> { - let (base_url, http_client) = auth_http_context()?; - let response = with_auth(http_client.post(format!("{}/auth/change-password", base_url)).json( - &ChangePasswordRequest { - current_password, - new_password, - }, - )) - .await - .send() - .await?; - - ensure_success(response).await?; - Ok(()) -} - -pub async fn reset_password(old_password: &str, new_password: &str) -> Result<(), AuthError> { - let (base_url, http_client) = auth_http_context()?; - let response = with_auth(http_client.post(format!("{}/auth/reset-password", base_url)).json( - &ResetPasswordRequest { - old_password, - new_password, - }, - )) - .await - .send() - .await?; - - ensure_success(response).await?; - Ok(()) -} - -pub async fn refresh_token(access_token: &str) -> Result { +pub async fn exchange_sso_code(code: &str) -> Result { let (base_url, http_client) = auth_http_context()?; let response = http_client - .post(format!("{}/auth/refresh", base_url)) - .header("Authorization", format!("Bearer {}", access_token)) + .post(format!("{}/auth/sso/exchange", base_url)) + .json(&ExchangeSsoCodeRequest { code }) .send() .await?; - if !response.status().is_success() { - let status = response.status(); - let error_text = response.text().await.unwrap_or_default(); - error!("Token refresh failed with status {}: {}", status, error_text); - return Err(AuthError::RefreshFailed); - } + let token_response: TokenResponse = ensure_success(response).await?.json().await?; + build_auth_pass( + token_response.access_token, + token_response.expires_in, + token_response.refresh_token, + token_response.refresh_expires_in, + ) +} + +pub async fn refresh_token(refresh_token: &str) -> Result { + let (base_url, http_client) = auth_http_context()?; + let response = http_client + .post(format!("{}/auth/refresh", base_url)) + .json(&RefreshTokenRequest { refresh_token }) + .send() + .await?; + + let token_response: TokenResponse = ensure_success(response).await?.json().await?; + let auth_pass = build_auth_pass( + token_response.access_token, + token_response.expires_in, + token_response.refresh_token, + token_response.refresh_expires_in, + )?; - let refresh_response: LoginResponse = response.json().await?; - let auth_pass = build_auth_pass(refresh_response.access_token, refresh_response.expires_in)?; lock_w!(FDOLL).auth.auth_pass = Some(auth_pass.clone()); save_auth_pass(&auth_pass)?; Ok(auth_pass) } + +pub async fn logout_remote(refresh_token: &str) -> Result<(), AuthError> { + let (base_url, http_client) = auth_http_context()?; + let response = http_client + .post(format!("{}/auth/logout", base_url)) + .json(&LogoutRequest { refresh_token }) + .send() + .await?; + + ensure_success(response).await?; + Ok(()) +} + +pub fn persist_auth_pass(auth_pass: &AuthPass) -> Result<(), AuthError> { + lock_w!(FDOLL).auth.auth_pass = Some(auth_pass.clone()); + save_auth_pass(auth_pass)?; + Ok(()) +} diff --git a/src-tauri/src/services/auth/flow.rs b/src-tauri/src/services/auth/flow.rs new file mode 100644 index 0000000..0e428ca --- /dev/null +++ b/src-tauri/src/services/auth/flow.rs @@ -0,0 +1,387 @@ +use tauri_specta::Event as _; +use tauri_plugin_opener::OpenerExt; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio_util::sync::CancellationToken; +use tracing::{error, info, warn}; + +use crate::get_app_handle; +use crate::services::app_events::{AuthFlowStatus, AuthFlowUpdated, AuthFlowUpdatedPayload}; +use crate::state::{begin_auth_flow, clear_auth_flow_state, is_auth_flow_active}; +use crate::{lock_r, state::FDOLL}; + +use super::api::{exchange_sso_code, persist_auth_pass, start_sso}; +use super::storage::AuthError; + +static AUTH_SUCCESS_HTML: &str = include_str!("../../assets/auth-success.html"); +static AUTH_CANCELLED_HTML: &str = include_str!("../../assets/auth-cancelled.html"); +static AUTH_FAILED_HTML: &str = include_str!("../../assets/auth-failed.html"); + +pub struct OAuthCallbackParams { + pub state: String, + pub result: OAuthCallbackResult, +} + +pub enum OAuthCallbackResult { + Code(String), + Error { message: String, cancelled: bool }, +} + +struct PendingOAuthCallback { + stream: TcpStream, + params: OAuthCallbackParams, +} + +pub async fn start_browser_auth_flow(provider: &str) -> Result<(), AuthError> { + let (flow_id, cancel_token) = begin_auth_flow(); + + let bind_addr = "127.0.0.1:0"; + let std_listener = std::net::TcpListener::bind(bind_addr) + .map_err(|e| AuthError::ServerBindError(e.to_string()))?; + std_listener + .set_nonblocking(true) + .map_err(|e| AuthError::ServerBindError(e.to_string()))?; + let local_addr = std_listener + .local_addr() + .map_err(|e| AuthError::ServerBindError(e.to_string()))?; + + let redirect_uri = format!("http://127.0.0.1:{}/callback", local_addr.port()); + let start_response = match start_sso(provider, &redirect_uri).await { + Ok(response) => response, + Err(err) => { + clear_auth_flow_state(flow_id); + return Err(err); + } + }; + + let listener = TcpListener::from_std(std_listener) + .map_err(|e| AuthError::ServerBindError(e.to_string()))?; + let expected_state = start_response.state.clone(); + let auth_url = match start_response.authorize_url.clone() { + Some(authorize_url) => authorize_url, + None => match build_authorize_url(provider, &start_response.state) { + Ok(url) => url, + Err(err) => { + clear_auth_flow_state(flow_id); + return Err(err); + } + }, + }; + let provider_name = provider.to_string(); + + if let Err(err) = get_app_handle().opener().open_url(auth_url, None::<&str>) { + clear_auth_flow_state(flow_id); + emit_auth_flow_event( + provider, + AuthFlowStatus::Failed, + Some("Friendolls could not open your browser for sign-in.".to_string()), + ); + return Err(err.into()); + } + + emit_auth_flow_event(provider, AuthFlowStatus::Started, None); + tauri::async_runtime::spawn(async move { + match listen_for_callback(listener, cancel_token.clone()).await { + Ok(mut callback) => { + if !is_auth_flow_active(flow_id) { + let _ = write_html_response(&mut callback.stream, AUTH_CANCELLED_HTML).await; + return; + } + + if callback.params.state != expected_state { + error!("SSO state mismatch"); + if let Err(err) = write_html_response(&mut callback.stream, AUTH_FAILED_HTML).await { + warn!("Failed to write auth failure response: {}", err); + } + emit_auth_flow_event( + &provider_name, + AuthFlowStatus::Failed, + Some("Sign-in verification failed. Please try again.".to_string()), + ); + clear_auth_flow_state(flow_id); + return; + } + + match callback.params.result { + OAuthCallbackResult::Code(code) => { + let auth_pass = match exchange_sso_code(&code).await { + Ok(auth_pass) => auth_pass, + Err(err) => { + error!("Failed to exchange SSO code: {}", err); + if let Err(write_err) = + write_html_response(&mut callback.stream, AUTH_FAILED_HTML).await + { + warn!("Failed to write auth failure response: {}", write_err); + } + emit_auth_flow_event( + &provider_name, + AuthFlowStatus::Failed, + Some( + "Friendolls could not complete sign-in. Please try again." + .to_string(), + ), + ); + clear_auth_flow_state(flow_id); + return; + } + }; + + if !is_auth_flow_active(flow_id) { + let _ = write_html_response(&mut callback.stream, AUTH_CANCELLED_HTML).await; + return; + } + + if let Err(err) = persist_auth_pass(&auth_pass) { + error!("Failed to persist SSO auth pass: {}", err); + if let Err(write_err) = + write_html_response(&mut callback.stream, AUTH_FAILED_HTML).await + { + warn!("Failed to write auth failure response: {}", write_err); + } + emit_auth_flow_event( + &provider_name, + AuthFlowStatus::Failed, + Some( + "Friendolls could not complete sign-in. Please try again." + .to_string(), + ), + ); + clear_auth_flow_state(flow_id); + return; + } + + if let Err(err) = super::session::finish_login_session().await { + error!("Failed to finalize desktop login session: {}", err); + if let Err(write_err) = + write_html_response(&mut callback.stream, AUTH_FAILED_HTML).await + { + warn!("Failed to write auth failure response: {}", write_err); + } + emit_auth_flow_event( + &provider_name, + AuthFlowStatus::Failed, + Some( + "Signed in, but Friendolls could not open your session." + .to_string(), + ), + ); + clear_auth_flow_state(flow_id); + } else { + if let Err(err) = + write_html_response(&mut callback.stream, AUTH_SUCCESS_HTML).await + { + warn!("Failed to write auth success response: {}", err); + } + emit_auth_flow_event(&provider_name, AuthFlowStatus::Succeeded, None); + clear_auth_flow_state(flow_id); + } + } + OAuthCallbackResult::Error { message, cancelled } => { + let response_html = if cancelled { + AUTH_CANCELLED_HTML + } else { + AUTH_FAILED_HTML + }; + if let Err(err) = write_html_response(&mut callback.stream, response_html).await { + warn!("Failed to write auth callback response: {}", err); + } + emit_auth_flow_event( + &provider_name, + if cancelled { + AuthFlowStatus::Cancelled + } else { + AuthFlowStatus::Failed + }, + Some(message), + ); + clear_auth_flow_state(flow_id); + } + } + } + Err(AuthError::Cancelled) => { + info!("Auth flow cancelled"); + if is_auth_flow_active(flow_id) { + emit_auth_flow_event( + &provider_name, + AuthFlowStatus::Cancelled, + Some("Sign-in was cancelled.".to_string()), + ); + clear_auth_flow_state(flow_id); + } + } + Err(err) => { + error!("Auth callback listener failed: {}", err); + if is_auth_flow_active(flow_id) { + emit_auth_flow_event( + &provider_name, + AuthFlowStatus::Failed, + Some(auth_flow_error_message(&err)), + ); + clear_auth_flow_state(flow_id); + } + } + } + }); + + Ok(()) +} + +fn build_authorize_url(provider: &str, state: &str) -> Result { + let base_url = lock_r!(FDOLL) + .app_config + .api_base_url + .clone() + .ok_or(AuthError::InvalidConfig)?; + + let mut parsed = url::Url::parse(&base_url) + .map_err(|e| AuthError::RequestFailed(format!("Invalid API base URL: {}", e)))?; + let existing_path = parsed.path().trim_end_matches('/'); + parsed.set_path(&format!("{}/auth/sso/{}", existing_path, provider)); + let query = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("state", state) + .finish(); + parsed.set_query(Some(&query)); + + Ok(parsed.to_string()) +} + +async fn listen_for_callback( + listener: TcpListener, + cancel_token: CancellationToken, +) -> Result { + let timeout = tokio::time::Duration::from_secs(300); + let start = tokio::time::Instant::now(); + + loop { + let elapsed = start.elapsed(); + if elapsed >= timeout { + return Err(AuthError::CallbackTimeout); + } + + let remaining = timeout - elapsed; + let accepted = tokio::select! { + _ = cancel_token.cancelled() => return Err(AuthError::Cancelled), + result = tokio::time::timeout(remaining, listener.accept()) => result, + }; + + let (mut stream, _) = match accepted { + Ok(Ok(value)) => value, + Ok(Err(err)) => { + warn!("Accept error in auth callback listener: {}", err); + continue; + } + Err(_) => return Err(AuthError::CallbackTimeout), + }; + + if let Some(params) = parse_callback(&mut stream).await? { + return Ok(PendingOAuthCallback { stream, params }); + } + } +} + +async fn parse_callback(stream: &mut TcpStream) -> Result, AuthError> { + let mut buffer = [0; 4096]; + let bytes_read = match stream.read(&mut buffer).await { + Ok(value) if value > 0 => value, + _ => return Ok(None), + }; + + let request = String::from_utf8_lossy(&buffer[..bytes_read]); + let first_line = request.lines().next().unwrap_or_default(); + let mut parts = first_line.split_whitespace(); + + match (parts.next(), parts.next()) { + (Some("GET"), Some(path)) if path.starts_with("/callback") => { + let parsed = url::Url::parse(&format!("http://localhost{}", path)) + .map_err(|e| AuthError::RequestFailed(e.to_string()))?; + let params: std::collections::HashMap<_, _> = parsed.query_pairs().into_owned().collect(); + + let state = params + .get("state") + .cloned() + .ok_or_else(|| AuthError::MissingParameter("state".to_string()))?; + if let Some(error_code) = params.get("error") { + let message = oauth_error_message(error_code, params.get("error_description")); + return Ok(Some(OAuthCallbackParams { + state, + result: OAuthCallbackResult::Error { + message, + cancelled: is_oauth_cancellation(error_code), + }, + })); + } + + let code = params + .get("code") + .cloned() + .ok_or_else(|| AuthError::MissingParameter("code".to_string()))?; + + Ok(Some(OAuthCallbackParams { + state, + result: OAuthCallbackResult::Code(code), + })) + } + (Some("GET"), Some("/health")) => { + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nOK") + .await?; + Ok(None) + } + _ => { + stream + .write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n") + .await?; + Ok(None) + } + } +} + +fn emit_auth_flow_event(provider: &str, status: AuthFlowStatus, message: Option) { + if let Err(err) = AuthFlowUpdated(AuthFlowUpdatedPayload { + provider: provider.to_string(), + status, + message, + }) + .emit(get_app_handle()) + { + warn!("Failed to emit auth flow event: {}", err); + } +} + +fn auth_flow_error_message(err: &AuthError) -> String { + match err { + AuthError::Cancelled => "Sign-in was cancelled.".to_string(), + AuthError::CallbackTimeout => "Sign-in timed out. Please try again.".to_string(), + AuthError::MissingParameter(_) => { + "Friendolls did not receive a complete sign-in response. Please try again.".to_string() + } + _ => "Friendolls could not complete sign-in. Please try again.".to_string(), + } +} + +fn oauth_error_message(error_code: &str, description: Option<&String>) -> String { + if let Some(description) = description.filter(|description| !description.is_empty()) { + return description.clone(); + } + + if is_oauth_cancellation(error_code) { + "Sign-in was cancelled.".to_string() + } else { + "The sign-in provider reported an error. Please try again.".to_string() + } +} + +fn is_oauth_cancellation(error_code: &str) -> bool { + matches!(error_code, "access_denied" | "user_cancelled" | "authorization_cancelled") +} + +async fn write_html_response(stream: &mut TcpStream, html: &str) -> Result<(), AuthError> { + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\n\r\n{}", + html.len(), + html + ); + stream.write_all(response.as_bytes()).await?; + stream.flush().await?; + Ok(()) +} diff --git a/src-tauri/src/services/auth/mod.rs b/src-tauri/src/services/auth/mod.rs index de1e09e..41a2437 100644 --- a/src-tauri/src/services/auth/mod.rs +++ b/src-tauri/src/services/auth/mod.rs @@ -1,7 +1,8 @@ mod api; +mod flow; mod session; mod storage; -pub use api::{change_password, refresh_token, register, reset_password, with_auth}; -pub use session::{get_access_token, get_session_token, login_and_init_session, logout_and_restart}; +pub use api::{refresh_token, with_auth}; +pub use session::{get_access_token, get_session_token, logout_and_restart, start_browser_login}; pub use storage::{clear_auth_pass, load_auth_pass, AuthPass}; diff --git a/src-tauri/src/services/auth/session.rs b/src-tauri/src/services/auth/session.rs index 5925ea2..a40c7b3 100644 --- a/src-tauri/src/services/auth/session.rs +++ b/src-tauri/src/services/auth/session.rs @@ -1,4 +1,5 @@ use tracing::info; +use tokio::time::{timeout, Duration}; use crate::get_app_handle; use crate::services::{scene::close_splash_window, session::construct_user_session, welcome::close_welcome_window}; @@ -15,21 +16,33 @@ pub async fn get_access_token() -> Option { get_session_token().await.map(|pass| pass.access_token) } -pub fn logout() -> Result<(), AuthError> { +pub async fn logout() -> Result<(), AuthError> { info!("Logging out user"); - lock_w!(FDOLL).auth.auth_pass = None; + let refresh_token = lock_w!(FDOLL) + .auth + .auth_pass + .take() + .and_then(|pass| pass.refresh_token); clear_auth_pass()?; + + if let Some(refresh_token) = refresh_token { + match timeout(Duration::from_secs(5), super::api::logout_remote(&refresh_token)).await { + Ok(Ok(())) => {} + Ok(Err(err)) => info!("Failed to revoke refresh token on server: {}", err), + Err(_) => info!("Timed out while revoking refresh token on server"), + } + } + Ok(()) } pub async fn logout_and_restart() -> Result<(), AuthError> { - logout()?; + logout().await?; let app_handle = get_app_handle(); app_handle.restart(); } -pub async fn login_and_init_session(email: &str, password: &str) -> Result<(), AuthError> { - super::api::login(email, password).await?; +pub async fn finish_login_session() -> Result<(), AuthError> { close_welcome_window(); tauri::async_runtime::spawn(async { construct_user_session().await; @@ -37,3 +50,7 @@ pub async fn login_and_init_session(email: &str, password: &str) -> Result<(), A }); Ok(()) } + +pub async fn start_browser_login(provider: &str) -> Result<(), AuthError> { + super::flow::start_browser_auth_flow(provider).await +} diff --git a/src-tauri/src/services/auth/storage.rs b/src-tauri/src/services/auth/storage.rs index 6cb5453..325cecd 100644 --- a/src-tauri/src/services/auth/storage.rs +++ b/src-tauri/src/services/auth/storage.rs @@ -29,6 +29,21 @@ pub enum AuthError { #[error("Request failed: {0}")] RequestFailed(String), + #[error("Missing callback parameter: {0}")] + MissingParameter(String), + + #[error("Authentication flow cancelled")] + Cancelled, + + #[error("Callback timeout - no response received")] + CallbackTimeout, + + #[error("Server binding failed: {0}")] + ServerBindError(String), + + #[error("Failed to open auth portal: {0}")] + OpenPortalFailed(#[from] tauri_plugin_opener::Error), + #[error("IO error: {0}")] IoError(#[from] std::io::Error), } @@ -37,12 +52,18 @@ pub enum AuthError { pub struct AuthPass { pub access_token: String, pub expires_in: u64, + #[serde(default)] + pub refresh_token: Option, + #[serde(default)] + pub refresh_expires_in: Option, pub issued_at: Option, } pub(crate) fn build_auth_pass( access_token: String, expires_in: u64, + refresh_token: String, + refresh_expires_in: u64, ) -> Result { let issued_at = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -51,6 +72,8 @@ pub(crate) fn build_auth_pass( Ok(AuthPass { access_token, expires_in, + refresh_token: Some(refresh_token), + refresh_expires_in: Some(refresh_expires_in), issued_at: Some(issued_at), }) } @@ -168,6 +191,10 @@ pub fn load_auth_pass() -> Result, AuthError> { } }; + if auth_pass.refresh_token.is_none() || auth_pass.refresh_expires_in.is_none() { + info!("Loaded legacy auth pass without refresh token support"); + } + Ok(Some(auth_pass)) } diff --git a/src-tauri/src/state/auth.rs b/src-tauri/src/state/auth.rs index 0f884e0..72fff4c 100644 --- a/src-tauri/src/state/auth.rs +++ b/src-tauri/src/state/auth.rs @@ -11,15 +11,30 @@ use tracing::{error, info, warn}; static REFRESH_LOCK: once_cell::sync::Lazy> = once_cell::sync::Lazy::new(|| Mutex::new(())); +#[derive(Default, Clone)] +pub struct OAuthFlowTracker { + pub active_flow_id: u64, + pub background_auth_token: Option, +} + #[derive(Default)] pub struct AuthState { pub auth_pass: Option, - pub background_refresh_token: Option, + pub oauth_flow: OAuthFlowTracker, + pub background_refresh_token: Option, } pub fn init_auth_state() -> AuthState { let auth_pass = match load_auth_pass() { - Ok(pass) => pass, + Ok(Some(pass)) if has_supported_auth_pass(&pass) => Some(pass), + Ok(Some(_)) => { + warn!("Discarding stored auth pass from unsupported auth format"); + if let Err(err) = clear_auth_pass() { + error!("Failed to clear unsupported auth pass: {}", err); + } + None + } + Ok(None) => None, Err(e) => { warn!("Failed to load auth pass from keyring: {e}"); None @@ -29,10 +44,50 @@ pub fn init_auth_state() -> AuthState { AuthState { auth_pass, + oauth_flow: OAuthFlowTracker::default(), background_refresh_token: None, } } +fn has_supported_auth_pass(auth_pass: &AuthPass) -> bool { + auth_pass.issued_at.is_some() + && auth_pass.refresh_token.is_some() + && auth_pass.refresh_expires_in.is_some() +} + +pub fn begin_auth_flow() -> (u64, CancellationToken) { + let mut guard = lock_w!(FDOLL); + if let Some(cancel_token) = guard.auth.oauth_flow.background_auth_token.take() { + cancel_token.cancel(); + } + + guard.auth.oauth_flow.active_flow_id = guard.auth.oauth_flow.active_flow_id.saturating_add(1); + let flow_id = guard.auth.oauth_flow.active_flow_id; + let cancel_token = CancellationToken::new(); + guard.auth.oauth_flow.background_auth_token = Some(cancel_token.clone()); + + (flow_id, cancel_token) +} + +pub fn clear_auth_flow_state(flow_id: u64) -> bool { + let mut guard = lock_w!(FDOLL); + if guard.auth.oauth_flow.active_flow_id != flow_id { + return false; + } + + if let Some(cancel_token) = guard.auth.oauth_flow.background_auth_token.take() { + cancel_token.cancel(); + } + + true +} + +pub fn is_auth_flow_active(flow_id: u64) -> bool { + let guard = lock_r!(FDOLL); + guard.auth.oauth_flow.active_flow_id == flow_id + && guard.auth.oauth_flow.background_auth_token.is_some() +} + /// Returns the auth pass object, including access token and metadata. /// Automatically refreshes if expired and clears session on refresh failure. pub async fn get_auth_pass_with_refresh() -> Option { @@ -41,14 +96,22 @@ pub async fn get_auth_pass_with_refresh() -> Option { let Some(issued_at) = auth_pass.issued_at else { warn!("Auth pass missing issued_at timestamp, clearing"); - lock_w!(FDOLL).auth.auth_pass = None; + clear_invalid_auth().await; return None; }; let current_time = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs(); - let expires_at = issued_at.saturating_add(auth_pass.expires_in); - let expired = current_time >= expires_at; - if !expired { + let access_expires_at = issued_at.saturating_add(auth_pass.expires_in); + let refresh_expires_at = auth_pass + .refresh_expires_in + .map(|refresh_expires_in| issued_at.saturating_add(refresh_expires_in)); + + if refresh_expires_at.is_some_and(|refresh_expires_at| current_time >= refresh_expires_at) { + clear_expired_auth().await; + return None; + } + + if current_time < access_expires_at { return Some(auth_pass); } @@ -58,31 +121,54 @@ pub async fn get_auth_pass_with_refresh() -> Option { let current_time = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs(); let Some(issued_at) = auth_pass.issued_at else { warn!("Auth pass missing issued_at timestamp after refresh lock, clearing"); - lock_w!(FDOLL).auth.auth_pass = None; + clear_invalid_auth().await; return None; }; - let expires_at = issued_at.saturating_add(auth_pass.expires_in); - let expired = current_time >= expires_at; - if !expired { + + let access_expires_at = issued_at.saturating_add(auth_pass.expires_in); + let refresh_expires_at = auth_pass + .refresh_expires_in + .map(|refresh_expires_in| issued_at.saturating_add(refresh_expires_in)); + + if refresh_expires_at.is_some_and(|refresh_expires_at| current_time >= refresh_expires_at) { + clear_expired_auth().await; + return None; + } + + if current_time < access_expires_at { return Some(auth_pass); } info!("Access token expired, attempting refresh"); - match refresh_token(&auth_pass.access_token).await { + let Some(refresh_token_value) = auth_pass.refresh_token.as_deref() else { + warn!("Auth pass missing refresh token, clearing session"); + clear_invalid_auth().await; + return None; + }; + + match refresh_token(refresh_token_value).await { Ok(new_pass) => Some(new_pass), Err(e) => { error!("Failed to refresh token: {}", e); - lock_w!(FDOLL).auth.auth_pass = None; - if let Err(e) = clear_auth_pass() { - error!("Failed to clear expired auth pass: {}", e); - } - destruct_user_session().await; - open_welcome_window(); + clear_expired_auth().await; None } } } +async fn clear_expired_auth() { + lock_w!(FDOLL).auth.auth_pass = None; + if let Err(e) = clear_auth_pass() { + error!("Failed to clear expired auth pass: {}", e); + } + destruct_user_session().await; + open_welcome_window(); +} + +async fn clear_invalid_auth() { + clear_expired_auth().await; +} + async fn refresh_if_expiring_soon() { let Some(auth_pass) = ({ lock_r!(FDOLL).auth.auth_pass.clone() }) else { return; @@ -98,6 +184,19 @@ async fn refresh_if_expiring_soon() { }; let access_expires_at = issued_at.saturating_add(auth_pass.expires_in); + if current_time >= access_expires_at && auth_pass.refresh_token.is_none() { + clear_expired_auth().await; + return; + } + + let refresh_expires_at = auth_pass + .refresh_expires_in + .map(|refresh_expires_in| issued_at.saturating_add(refresh_expires_in)); + if refresh_expires_at.is_some_and(|refresh_expires_at| current_time >= refresh_expires_at) { + clear_expired_auth().await; + return; + } + if access_expires_at.saturating_sub(current_time) >= 60 { return; } @@ -118,18 +217,30 @@ async fn refresh_if_expiring_soon() { }; let access_expires_at = latest_issued_at.saturating_add(latest_pass.expires_in); + if current_time >= access_expires_at && latest_pass.refresh_token.is_none() { + clear_expired_auth().await; + return; + } + + let refresh_expires_at = latest_pass + .refresh_expires_in + .map(|refresh_expires_in| latest_issued_at.saturating_add(refresh_expires_in)); + if refresh_expires_at.is_some_and(|refresh_expires_at| current_time >= refresh_expires_at) { + clear_expired_auth().await; + return; + } + if access_expires_at.saturating_sub(current_time) >= 60 { return; } - if let Err(e) = refresh_token(&latest_pass.access_token).await { + let Some(refresh_token_value) = latest_pass.refresh_token.as_deref() else { + return; + }; + + if let Err(e) = refresh_token(refresh_token_value).await { warn!("Background refresh failed: {}", e); - lock_w!(FDOLL).auth.auth_pass = None; - if let Err(e) = clear_auth_pass() { - error!("Failed to clear auth pass after refresh failure: {}", e); - } - destruct_user_session().await; - open_welcome_window(); + clear_expired_auth().await; } } diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index 4460634..562fd48 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -104,17 +104,11 @@ async setSceneInteractive(interactive: boolean, shouldClick: boolean) : Promise< async setPetMenuState(id: string, open: boolean) : Promise { await TAURI_INVOKE("set_pet_menu_state", { id, open }); }, -async login(email: string, password: string) : Promise { - return await TAURI_INVOKE("login", { email, password }); +async startGoogleAuth() : Promise { + return await TAURI_INVOKE("start_google_auth"); }, -async register(email: string, password: string, name: string | null, username: string | null) : Promise { - return await TAURI_INVOKE("register", { email, password, name, username }); -}, -async changePassword(currentPassword: string, newPassword: string) : Promise { - return await TAURI_INVOKE("change_password", { currentPassword, newPassword }); -}, -async resetPassword(oldPassword: string, newPassword: string) : Promise { - return await TAURI_INVOKE("reset_password", { oldPassword, newPassword }); +async startDiscordAuth() : Promise { + return await TAURI_INVOKE("start_discord_auth"); }, async logoutAndRestart() : Promise { return await TAURI_INVOKE("logout_and_restart"); @@ -133,6 +127,7 @@ async getModules() : Promise { export const events = __makeEvents__<{ activeDollSpriteChanged: ActiveDollSpriteChanged, appDataRefreshed: AppDataRefreshed, +authFlowUpdated: AuthFlowUpdated, createDoll: CreateDoll, cursorMoved: CursorMoved, editDoll: EditDoll, @@ -153,6 +148,7 @@ userStatusChanged: UserStatusChanged }>({ activeDollSpriteChanged: "active-doll-sprite-changed", appDataRefreshed: "app-data-refreshed", +authFlowUpdated: "auth-flow-updated", createDoll: "create-doll", cursorMoved: "cursor-moved", editDoll: "edit-doll", @@ -181,6 +177,9 @@ userStatusChanged: "user-status-changed" export type ActiveDollSpriteChanged = string | null export type AppConfig = { api_base_url: string | null } export type AppDataRefreshed = UserData +export type AuthFlowStatus = "started" | "succeeded" | "failed" | "cancelled" +export type AuthFlowUpdated = AuthFlowUpdatedPayload +export type AuthFlowUpdatedPayload = { provider: string; status: AuthFlowStatus; message: string | null } export type CreateDoll = null export type CreateDollDto = { name: string; configuration: DollConfigurationDto | null } export type CursorMoved = CursorPositions diff --git a/src/routes/app-menu/tabs/preferences.svelte b/src/routes/app-menu/tabs/preferences.svelte index 0672935..d665141 100644 --- a/src/routes/app-menu/tabs/preferences.svelte +++ b/src/routes/app-menu/tabs/preferences.svelte @@ -4,14 +4,6 @@ import Power from "../../../assets/icons/power.svelte"; let signingOut = false; - let isChangingPassword = false; - let passwordError = ""; - let passwordSuccess = ""; - let passwordForm = { - currentPassword: "", - newPassword: "", - confirmPassword: "", - }; async function handleSignOut() { if (signingOut) return; @@ -31,99 +23,23 @@ console.error("Failed to open client config", error); } }; - - const handleChangePassword = async () => { - if (isChangingPassword) return; - passwordError = ""; - passwordSuccess = ""; - - if (!passwordForm.currentPassword || !passwordForm.newPassword) { - passwordError = "Current and new password are required"; - return; - } - - if (passwordForm.newPassword !== passwordForm.confirmPassword) { - passwordError = "New password confirmation does not match"; - return; - } - - isChangingPassword = true; - try { - await commands.changePassword( - passwordForm.currentPassword, - passwordForm.newPassword, - ); - passwordSuccess = "Password updated"; - passwordForm.currentPassword = ""; - passwordForm.newPassword = ""; - passwordForm.confirmPassword = ""; - } catch (error) { - console.error("Failed to change password", error); - passwordError = error instanceof Error ? error.message : "Unable to update password"; - } finally { - isChangingPassword = false; - } - };
-
+

{$appData?.user?.name}'s preferences

-
-
-
-

Change password

- - - -
- - {#if passwordSuccess} - {passwordSuccess} - {/if} -
- {#if passwordError} -

{passwordError}

- {/if} -
diff --git a/src/routes/welcome/+page.svelte b/src/routes/welcome/+page.svelte index e526e93..dda2b9b 100644 --- a/src/routes/welcome/+page.svelte +++ b/src/routes/welcome/+page.svelte @@ -1,61 +1,77 @@