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..d12b43a 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,
@@ -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
diff --git a/src-tauri/src/services/auth/api.rs b/src-tauri/src/services/auth/api.rs
index 750644c..6a3c182 100644
--- a/src-tauri/src/services/auth/api.rs
+++ b/src-tauri/src/services/auth/api.rs
@@ -6,48 +6,44 @@ 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,
+}
+
+#[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 +83,70 @@ 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?;
+ 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 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,
+ )?;
+
+ 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(())
+}
diff --git a/src-tauri/src/services/auth/flow.rs b/src-tauri/src/services/auth/flow.rs
new file mode 100644
index 0000000..7a6da38
--- /dev/null
+++ b/src-tauri/src/services/auth/flow.rs
@@ -0,0 +1,187 @@
+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::state::clear_auth_flow_state;
+use crate::{lock_r, state::FDOLL};
+
+use super::api::{exchange_sso_code, start_sso};
+use super::storage::AuthError;
+
+static AUTH_SUCCESS_HTML: &str = include_str!("../../assets/auth-success.html");
+
+pub struct OAuthCallbackParams {
+ pub state: String,
+ pub code: String,
+}
+
+pub async fn start_browser_auth_flow(provider: &str) -> Result<(), AuthError> {
+ clear_auth_flow_state();
+
+ 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 = start_sso(provider, &redirect_uri).await?;
+
+ 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)?;
+ 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");
+ 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;
+ }
+
+ clear_auth_flow_state();
+ if let Err(err) = super::session::finish_login_session().await {
+ error!("Failed to finalize desktop login session: {}", err);
+ }
+ }
+ Err(AuthError::Cancelled) => {
+ info!("Auth flow cancelled");
+ }
+ Err(err) => {
+ error!("Auth callback listener failed: {}", err);
+ clear_auth_flow_state();
+ }
+ }
+ });
+
+ get_app_handle()
+ .opener()
+ .open_url(auth_url, None::<&str>)?;
+
+ 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(callback) = parse_callback(&mut stream).await? {
+ return Ok(callback);
+ }
+ }
+}
+
+async fn parse_callback(stream: &mut TcpStream) -> Result