From 072b94626838af2fdb8d18b9b4b4b3e2d80b6fee Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Tue, 17 Mar 2026 13:34:04 +0800 Subject: [PATCH] SSO auth pt 2 --- src-tauri/src/assets/auth-cancelled.html | 51 +++++++ src-tauri/src/assets/auth-failed.html | 51 +++++++ src-tauri/src/lib.rs | 9 +- src-tauri/src/services/app_events.rs | 21 +++ src-tauri/src/services/auth/api.rs | 2 + src-tauri/src/services/auth/flow.rs | 174 ++++++++++++++++++++--- src-tauri/src/services/auth/session.rs | 17 +-- src-tauri/src/services/auth/storage.rs | 14 +- src-tauri/src/state/auth.rs | 54 +++++-- src/lib/bindings.ts | 5 + src/routes/welcome/+page.svelte | 44 +++++- 11 files changed, 388 insertions(+), 54 deletions(-) create mode 100644 src-tauri/src/assets/auth-cancelled.html create mode 100644 src-tauri/src/assets/auth-failed.html 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/lib.rs b/src-tauri/src/lib.rs index d12b43a..16a65ea 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, @@ -124,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 6a3c182..f2afd93 100644 --- a/src-tauri/src/services/auth/api.rs +++ b/src-tauri/src/services/auth/api.rs @@ -8,6 +8,8 @@ use super::storage::{build_auth_pass, save_auth_pass, AuthError, AuthPass}; #[derive(Debug, Deserialize)] pub struct StartSsoResponse { pub state: String, + #[serde(rename = "authorizeUrl")] + pub authorize_url: Option, } #[derive(Debug, Deserialize)] diff --git a/src-tauri/src/services/auth/flow.rs b/src-tauri/src/services/auth/flow.rs index 7a6da38..d6f2eb3 100644 --- a/src-tauri/src/services/auth/flow.rs +++ b/src-tauri/src/services/auth/flow.rs @@ -1,3 +1,4 @@ +use tauri_specta::Event as _; use tauri_plugin_opener::OpenerExt; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; @@ -5,6 +6,7 @@ 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::clear_auth_flow_state; use crate::{lock_r, state::FDOLL}; @@ -12,10 +14,17 @@ use super::api::{exchange_sso_code, 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 code: String, + pub result: OAuthCallbackResult, +} + +pub enum OAuthCallbackResult { + Code(String), + Error { message: String, cancelled: bool }, } pub async fn start_browser_auth_flow(provider: &str) -> Result<(), AuthError> { @@ -37,48 +46,103 @@ pub async fn start_browser_auth_flow(provider: &str) -> Result<(), AuthError> { let cancel_token = CancellationToken::new(); { let mut guard = crate::lock_w!(crate::state::FDOLL); - guard.auth.oauth_flow.state = Some(start_response.state.clone()); guard.auth.oauth_flow.background_auth_token = Some(cancel_token.clone()); } 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 = build_authorize_url(provider, &start_response.state)?; + let auth_url = match start_response.authorize_url.clone() { + Some(authorize_url) => authorize_url, + None => build_authorize_url(provider, &start_response.state)?, + }; + let provider_name = provider.to_string(); + + if let Err(err) = get_app_handle().opener().open_url(auth_url, None::<&str>) { + clear_auth_flow_state(); + 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(params) => { if params.state != expected_state { error!("SSO state mismatch"); + emit_auth_flow_event( + &provider_name, + AuthFlowStatus::Failed, + Some("Sign-in verification failed. Please try again.".to_string()), + ); clear_auth_flow_state(); return; } - if let Err(err) = exchange_sso_code(¶ms.code).await { - error!("Failed to exchange SSO code: {}", err); - clear_auth_flow_state(); - return; - } + match params.result { + OAuthCallbackResult::Code(code) => { + if let Err(err) = exchange_sso_code(&code).await { + error!("Failed to exchange SSO code: {}", 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(); + return; + } - clear_auth_flow_state(); - if let Err(err) = super::session::finish_login_session().await { - error!("Failed to finalize desktop login session: {}", err); + clear_auth_flow_state(); + if let Err(err) = super::session::finish_login_session().await { + error!("Failed to finalize desktop login session: {}", err); + emit_auth_flow_event( + &provider_name, + AuthFlowStatus::Failed, + Some("Signed in, but Friendolls could not open your session.".to_string()), + ); + } else { + emit_auth_flow_event(&provider_name, AuthFlowStatus::Succeeded, None); + } + } + OAuthCallbackResult::Error { message, cancelled } => { + clear_auth_flow_state(); + emit_auth_flow_event( + &provider_name, + if cancelled { + AuthFlowStatus::Cancelled + } else { + AuthFlowStatus::Failed + }, + Some(message), + ); + } } } Err(AuthError::Cancelled) => { info!("Auth flow cancelled"); + emit_auth_flow_event( + &provider_name, + AuthFlowStatus::Cancelled, + Some("Sign-in was cancelled.".to_string()), + ); + clear_auth_flow_state(); } Err(err) => { error!("Auth callback listener failed: {}", err); + emit_auth_flow_event( + &provider_name, + AuthFlowStatus::Failed, + Some(auth_flow_error_message(&err)), + ); clear_auth_flow_state(); } } }); - get_app_handle() - .opener() - .open_url(auth_url, None::<&str>)?; - Ok(()) } @@ -156,20 +220,36 @@ async fn parse_callback(stream: &mut TcpStream) -> Result { stream @@ -185,3 +265,53 @@ async fn parse_callback(stream: &mut TcpStream) -> Result) { + 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/session.rs b/src-tauri/src/services/auth/session.rs index 0cb3ecf..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,28 +16,28 @@ 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"); let refresh_token = lock_w!(FDOLL) .auth .auth_pass .take() - .map(|pass| pass.refresh_token); + .and_then(|pass| pass.refresh_token); clear_auth_pass()?; if let Some(refresh_token) = refresh_token { - tauri::async_runtime::spawn(async move { - if let Err(err) = super::api::logout_remote(&refresh_token).await { - info!("Failed to revoke refresh token on server: {}", err); - } - }); + 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(); } diff --git a/src-tauri/src/services/auth/storage.rs b/src-tauri/src/services/auth/storage.rs index f52d8e3..325cecd 100644 --- a/src-tauri/src/services/auth/storage.rs +++ b/src-tauri/src/services/auth/storage.rs @@ -52,8 +52,10 @@ pub enum AuthError { pub struct AuthPass { pub access_token: String, pub expires_in: u64, - pub refresh_token: String, - pub refresh_expires_in: u64, + #[serde(default)] + pub refresh_token: Option, + #[serde(default)] + pub refresh_expires_in: Option, pub issued_at: Option, } @@ -70,8 +72,8 @@ pub(crate) fn build_auth_pass( Ok(AuthPass { access_token, expires_in, - refresh_token, - refresh_expires_in, + refresh_token: Some(refresh_token), + refresh_expires_in: Some(refresh_expires_in), issued_at: Some(issued_at), }) } @@ -189,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 ccd2b25..1252abf 100644 --- a/src-tauri/src/state/auth.rs +++ b/src-tauri/src/state/auth.rs @@ -13,7 +13,6 @@ static REFRESH_LOCK: once_cell::sync::Lazy> = #[derive(Default, Clone)] pub struct OAuthFlowTracker { - pub state: Option, pub background_auth_token: Option, } @@ -46,7 +45,6 @@ pub fn clear_auth_flow_state() { if let Some(cancel_token) = guard.auth.oauth_flow.background_auth_token.take() { cancel_token.cancel(); } - guard.auth.oauth_flow.state = None; } /// Returns the auth pass object, including access token and metadata. @@ -63,9 +61,11 @@ pub async fn get_auth_pass_with_refresh() -> Option { let current_time = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs(); let access_expires_at = issued_at.saturating_add(auth_pass.expires_in); - let refresh_expires_at = issued_at.saturating_add(auth_pass.refresh_expires_in); + let refresh_expires_at = auth_pass + .refresh_expires_in + .map(|refresh_expires_in| issued_at.saturating_add(refresh_expires_in)); - if current_time >= refresh_expires_at { + if refresh_expires_at.is_some_and(|refresh_expires_at| current_time >= refresh_expires_at) { clear_expired_auth().await; return None; } @@ -85,9 +85,11 @@ pub async fn get_auth_pass_with_refresh() -> Option { }; let access_expires_at = issued_at.saturating_add(auth_pass.expires_in); - let refresh_expires_at = issued_at.saturating_add(auth_pass.refresh_expires_in); + let refresh_expires_at = auth_pass + .refresh_expires_in + .map(|refresh_expires_in| issued_at.saturating_add(refresh_expires_in)); - if current_time >= refresh_expires_at { + if refresh_expires_at.is_some_and(|refresh_expires_at| current_time >= refresh_expires_at) { clear_expired_auth().await; return None; } @@ -97,7 +99,13 @@ pub async fn get_auth_pass_with_refresh() -> Option { } info!("Access token expired, attempting refresh"); - match refresh_token(&auth_pass.refresh_token).await { + let Some(refresh_token_value) = auth_pass.refresh_token.as_deref() else { + warn!("Legacy auth pass missing refresh token, clearing expired session"); + clear_expired_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); @@ -130,13 +138,20 @@ async fn refresh_if_expiring_soon() { Err(_) => return, }; - let refresh_expires_at = issued_at.saturating_add(auth_pass.refresh_expires_in); - if current_time >= refresh_expires_at { + 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; } - let access_expires_at = issued_at.saturating_add(auth_pass.expires_in); if access_expires_at.saturating_sub(current_time) >= 60 { return; } @@ -156,18 +171,29 @@ async fn refresh_if_expiring_soon() { Err(_) => return, }; - let refresh_expires_at = latest_issued_at.saturating_add(latest_pass.refresh_expires_in); - if current_time >= refresh_expires_at { + 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; } - let access_expires_at = latest_issued_at.saturating_add(latest_pass.expires_in); if access_expires_at.saturating_sub(current_time) >= 60 { return; } - if let Err(e) = refresh_token(&latest_pass.refresh_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); clear_expired_auth().await; } diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index c8dd31a..562fd48 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -127,6 +127,7 @@ async getModules() : Promise { export const events = __makeEvents__<{ activeDollSpriteChanged: ActiveDollSpriteChanged, appDataRefreshed: AppDataRefreshed, +authFlowUpdated: AuthFlowUpdated, createDoll: CreateDoll, cursorMoved: CursorMoved, editDoll: EditDoll, @@ -147,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", @@ -175,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/welcome/+page.svelte b/src/routes/welcome/+page.svelte index 1e1ce65..6c2bb79 100644 --- a/src/routes/welcome/+page.svelte +++ b/src/routes/welcome/+page.svelte @@ -1,10 +1,19 @@