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