native auth
This commit is contained in:
@@ -1,3 +1 @@
|
||||
API_BASE_URL=http://127.0.0.1:3000
|
||||
AUTH_URL=https://auth.example.com/realms/friendolls/protocol/openid-connect
|
||||
JWT_AUDIENCE=friendolls-desktop
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authentication Successful</title>
|
||||
<style>
|
||||
body {
|
||||
font-family:
|
||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
max-width: 330px;
|
||||
}
|
||||
h1 {
|
||||
color: #2d3748;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
p {
|
||||
color: #718096;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.checkmark {
|
||||
font-size: 4rem;
|
||||
color: #48bb78;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="checkmark">✓</div>
|
||||
<h1>Signed in!</h1>
|
||||
<p>You have been successfully authenticated.</p>
|
||||
<p>You may now close this window and return to the application.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,27 +1,47 @@
|
||||
use tauri;
|
||||
use tracing;
|
||||
|
||||
use crate::{init::lifecycle::construct_user_session, services::scene::close_splash_window};
|
||||
use crate::services::auth;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn logout_and_restart() -> Result<(), String> {
|
||||
crate::services::auth::logout_and_restart()
|
||||
auth::logout_and_restart().await.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn login(email: String, password: String) -> Result<(), String> {
|
||||
auth::login_and_init_session(&email, &password)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn start_auth_flow() -> Result<(), String> {
|
||||
// Cancel any in-flight auth listener/state before starting a new one
|
||||
crate::services::auth::cancel_auth_flow();
|
||||
|
||||
crate::services::auth::init_auth_code_retrieval(|| {
|
||||
tracing::info!("Authentication successful, constructing user session...");
|
||||
crate::services::welcome::close_welcome_window();
|
||||
tauri::async_runtime::spawn(async {
|
||||
construct_user_session().await;
|
||||
close_splash_window();
|
||||
});
|
||||
})
|
||||
pub async fn register(
|
||||
email: String,
|
||||
password: String,
|
||||
name: Option<String>,
|
||||
username: Option<String>,
|
||||
) -> Result<String, String> {
|
||||
auth::register(
|
||||
&email,
|
||||
&password,
|
||||
name.as_deref(),
|
||||
username.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
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]
|
||||
pub async fn reset_password(old_password: String, new_password: String) -> Result<(), String> {
|
||||
auth::reset_password(&old_password, &new_password)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use crate::services::{
|
||||
};
|
||||
use commands::app::{quit_app, restart_app, retry_connection};
|
||||
use commands::app_data::{get_app_data, refresh_app_data};
|
||||
use commands::auth::{logout_and_restart, start_auth_flow};
|
||||
use commands::auth::{change_password, login, logout_and_restart, register, reset_password};
|
||||
use commands::config::{get_client_config, open_client_config_manager, save_client_config};
|
||||
use commands::dolls::{
|
||||
create_doll, delete_doll, get_doll, get_dolls, remove_active_doll, set_active_doll, update_doll,
|
||||
@@ -81,7 +81,10 @@ pub fn run() {
|
||||
open_doll_editor_window,
|
||||
set_scene_interactive,
|
||||
set_pet_menu_state,
|
||||
start_auth_flow,
|
||||
login,
|
||||
register,
|
||||
change_password,
|
||||
reset_password,
|
||||
logout_and_restart,
|
||||
send_interaction_cmd,
|
||||
send_user_status_cmd
|
||||
|
||||
@@ -6,7 +6,6 @@ use ts_rs::TS;
|
||||
#[ts(export)]
|
||||
pub struct UserProfile {
|
||||
pub id: String,
|
||||
pub keycloak_sub: String,
|
||||
pub name: String,
|
||||
pub email: String,
|
||||
pub username: Option<String>,
|
||||
@@ -16,4 +15,4 @@ pub struct UserProfile {
|
||||
pub updated_at: String,
|
||||
pub last_login_at: Option<String>,
|
||||
pub active_doll_id: Option<String>,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
pub mod dolls;
|
||||
pub mod friends;
|
||||
pub mod health;
|
||||
pub mod session;
|
||||
pub mod user;
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
use reqwest::Error;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::services::auth::with_auth;
|
||||
use crate::{lock_r, state::FDOLL};
|
||||
|
||||
pub struct SessionRemote {
|
||||
pub base_url: String,
|
||||
pub client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl SessionRemote {
|
||||
pub fn new() -> Self {
|
||||
let guard = lock_r!(FDOLL);
|
||||
Self {
|
||||
base_url: guard
|
||||
.app_config
|
||||
.api_base_url
|
||||
.as_ref()
|
||||
.expect("App configuration error")
|
||||
.clone(),
|
||||
client: guard
|
||||
.network.clients
|
||||
.as_ref()
|
||||
.expect("App configuration error")
|
||||
.http_client
|
||||
.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn logout(
|
||||
&self,
|
||||
refresh_token: &str,
|
||||
session_state: Option<&str>,
|
||||
) -> Result<(), Error> {
|
||||
let url = format!("{}/users/logout", self.base_url);
|
||||
let body = json!({
|
||||
"refreshToken": refresh_token,
|
||||
"sessionState": session_state,
|
||||
});
|
||||
let resp = with_auth(self.client.post(url))
|
||||
.await
|
||||
.json(&body)
|
||||
.send()
|
||||
.await?;
|
||||
resp.error_for_status()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,16 +8,9 @@ use url::Url;
|
||||
|
||||
use crate::get_app_handle;
|
||||
|
||||
#[derive(Default, Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct AuthConfig {
|
||||
pub audience: String,
|
||||
pub auth_url: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct AppConfig {
|
||||
pub api_base_url: Option<String>,
|
||||
pub auth: AuthConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
@@ -39,8 +32,6 @@ pub enum ClientConfigError {
|
||||
pub static CLIENT_CONFIG_MANAGER_WINDOW_LABEL: &str = "client_config_manager";
|
||||
const CONFIG_FILENAME: &str = "client_config.json";
|
||||
const DEFAULT_API_BASE_URL: &str = "https://api.fdolls.adamcv.com";
|
||||
const DEFAULT_AUTH_URL: &str = "https://auth.adamcv.com/realms/friendolls/protocol/openid-connect";
|
||||
const DEFAULT_JWT_AUDIENCE: &str = "friendolls-desktop";
|
||||
|
||||
fn config_file_path(app_handle: &tauri::AppHandle) -> Result<PathBuf, ClientConfigError> {
|
||||
let dir = app_handle
|
||||
@@ -79,26 +70,12 @@ fn sanitize(mut config: AppConfig) -> AppConfig {
|
||||
.or_else(|| Some(DEFAULT_API_BASE_URL.to_string()))
|
||||
.map(|v| strip_trailing_slash(&v));
|
||||
|
||||
let auth_url_trimmed = config.auth.auth_url.trim();
|
||||
config.auth.auth_url =
|
||||
parse_http_url(auth_url_trimmed).unwrap_or_else(|| DEFAULT_AUTH_URL.to_string());
|
||||
|
||||
if config.auth.audience.trim().is_empty() {
|
||||
config.auth.audience = DEFAULT_JWT_AUDIENCE.to_string();
|
||||
} else {
|
||||
config.auth.audience = config.auth.audience.trim().to_string();
|
||||
}
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
pub fn default_app_config() -> AppConfig {
|
||||
AppConfig {
|
||||
api_base_url: Some(DEFAULT_API_BASE_URL.to_string()),
|
||||
auth: AuthConfig {
|
||||
audience: DEFAULT_JWT_AUDIENCE.to_string(),
|
||||
auth_url: DEFAULT_AUTH_URL.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,17 +12,8 @@ use tracing::{error, info, warn};
|
||||
static REFRESH_LOCK: once_cell::sync::Lazy<Mutex<()>> =
|
||||
once_cell::sync::Lazy::new(|| Mutex::new(()));
|
||||
|
||||
#[derive(Default, Clone)]
|
||||
pub struct OAuthFlowTracker {
|
||||
pub state: Option<String>,
|
||||
pub code_verifier: Option<String>,
|
||||
pub initiated_at: Option<u64>,
|
||||
pub cancel_token: Option<tokio_util::sync::CancellationToken>,
|
||||
}
|
||||
|
||||
pub struct AuthState {
|
||||
pub auth_pass: Option<AuthPass>,
|
||||
pub oauth_flow: OAuthFlowTracker,
|
||||
pub background_refresh_token: Option<tokio_util::sync::CancellationToken>,
|
||||
}
|
||||
|
||||
@@ -30,7 +21,6 @@ impl Default for AuthState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auth_pass: None,
|
||||
oauth_flow: OAuthFlowTracker::default(),
|
||||
background_refresh_token: None,
|
||||
}
|
||||
}
|
||||
@@ -48,13 +38,12 @@ pub fn init_auth_state() -> AuthState {
|
||||
|
||||
AuthState {
|
||||
auth_pass,
|
||||
oauth_flow: OAuthFlowTracker::default(),
|
||||
background_refresh_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the auth pass object, including access token, refresh token, and metadata.
|
||||
/// Automatically refreshes if expired and clears session if refresh token is expired.
|
||||
/// 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<AuthPass> {
|
||||
info!("Retrieving tokens");
|
||||
let Some(auth_pass) = ({ lock_r!(FDOLL).auth.auth_pass.clone() }) else {
|
||||
@@ -68,24 +57,12 @@ pub async fn get_auth_pass_with_refresh() -> Option<AuthPass> {
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
let expires_at = issued_at.saturating_add(auth_pass.expires_in);
|
||||
let expired = current_time >= expires_at;
|
||||
if !expired {
|
||||
return Some(auth_pass);
|
||||
}
|
||||
|
||||
if refresh_expired {
|
||||
info!("Refresh token expired, clearing auth state");
|
||||
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();
|
||||
return None;
|
||||
}
|
||||
|
||||
let _guard = REFRESH_LOCK.lock().await;
|
||||
|
||||
let auth_pass = lock_r!(FDOLL).auth.auth_pass.clone()?;
|
||||
@@ -95,33 +72,23 @@ pub async fn get_auth_pass_with_refresh() -> Option<AuthPass> {
|
||||
lock_w!(FDOLL).auth.auth_pass = None;
|
||||
return None;
|
||||
};
|
||||
let expired = current_time - issued_at >= auth_pass.expires_in;
|
||||
let refresh_expired = current_time - issued_at >= auth_pass.refresh_expires_in;
|
||||
|
||||
if refresh_expired {
|
||||
info!("Refresh token expired, clearing auth state after refresh lock");
|
||||
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();
|
||||
return None;
|
||||
}
|
||||
|
||||
let expires_at = issued_at.saturating_add(auth_pass.expires_in);
|
||||
let expired = current_time >= expires_at;
|
||||
if !expired {
|
||||
return Some(auth_pass);
|
||||
}
|
||||
|
||||
info!("Access token expired, attempting refresh");
|
||||
match refresh_token(&auth_pass.refresh_token).await {
|
||||
match refresh_token(&auth_pass.access_token).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 auth pass after refresh failure: {}", e);
|
||||
error!("Failed to clear expired auth pass: {}", e);
|
||||
}
|
||||
destruct_user_session().await;
|
||||
open_welcome_window();
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -141,17 +108,6 @@ 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 {
|
||||
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();
|
||||
return;
|
||||
}
|
||||
|
||||
let access_expires_at = issued_at.saturating_add(auth_pass.expires_in);
|
||||
if access_expires_at.saturating_sub(current_time) >= 60 {
|
||||
return;
|
||||
@@ -172,24 +128,19 @@ 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 {
|
||||
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();
|
||||
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 {
|
||||
if let Err(e) = refresh_token(&latest_pass.access_token).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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user