sign in auth flow 👍
This commit is contained in:
94
src-tauri/Cargo.lock
generated
94
src-tauri/Cargo.lock
generated
@@ -47,6 +47,12 @@ version = "1.0.100"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ascii"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-broadcast"
|
name = "async-broadcast"
|
||||||
version = "0.7.2"
|
version = "0.7.2"
|
||||||
@@ -456,6 +462,12 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chunked_transfer"
|
||||||
|
version = "1.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6e4de3bc4ea267985becf712dc6d9eed8b04c953b3fcfb339ebc87acd9804901"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -1047,6 +1059,7 @@ dependencies = [
|
|||||||
"device_query",
|
"device_query",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"keyring",
|
"keyring",
|
||||||
|
"once_cell",
|
||||||
"rand 0.9.2",
|
"rand 0.9.2",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1057,7 +1070,11 @@ dependencies = [
|
|||||||
"tauri-plugin-global-shortcut",
|
"tauri-plugin-global-shortcut",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
"tauri-plugin-positioner",
|
"tauri-plugin-positioner",
|
||||||
|
"thiserror 1.0.69",
|
||||||
|
"tiny_http",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
"ts-rs",
|
"ts-rs",
|
||||||
"url",
|
"url",
|
||||||
]
|
]
|
||||||
@@ -1593,6 +1610,12 @@ version = "1.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpdate"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.7.0"
|
version = "1.7.0"
|
||||||
@@ -2277,6 +2300,15 @@ version = "0.1.14"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nu-ansi-term"
|
||||||
|
version = "0.50.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -3688,6 +3720,15 @@ dependencies = [
|
|||||||
"digest",
|
"digest",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sharded-slab"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||||
|
dependencies = [
|
||||||
|
"lazy_static",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "1.3.0"
|
version = "1.3.0"
|
||||||
@@ -4341,6 +4382,15 @@ dependencies = [
|
|||||||
"syn 2.0.110",
|
"syn 2.0.110",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thread_local"
|
||||||
|
version = "1.1.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time"
|
name = "time"
|
||||||
version = "0.3.44"
|
version = "0.3.44"
|
||||||
@@ -4372,6 +4422,18 @@ dependencies = [
|
|||||||
"time-core",
|
"time-core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tiny_http"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "389915df6413a2e74fb181895f933386023c71110878cd0825588928e64cdc82"
|
||||||
|
dependencies = [
|
||||||
|
"ascii",
|
||||||
|
"chunked_transfer",
|
||||||
|
"httpdate",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinystr"
|
name = "tinystr"
|
||||||
version = "0.8.2"
|
version = "0.8.2"
|
||||||
@@ -4610,6 +4672,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
|
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"valuable",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-log"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"once_cell",
|
||||||
|
"tracing-core",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tracing-subscriber"
|
||||||
|
version = "0.3.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5"
|
||||||
|
dependencies = [
|
||||||
|
"nu-ansi-term",
|
||||||
|
"sharded-slab",
|
||||||
|
"smallvec",
|
||||||
|
"thread_local",
|
||||||
|
"tracing-core",
|
||||||
|
"tracing-log",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4792,6 +4880,12 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "valuable"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vcpkg"
|
name = "vcpkg"
|
||||||
version = "0.2.15"
|
version = "0.2.15"
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ url = "2.5.7"
|
|||||||
rand = "0.9.2"
|
rand = "0.9.2"
|
||||||
sha2 = "0.10.9"
|
sha2 = "0.10.9"
|
||||||
base64 = "0.22.1"
|
base64 = "0.22.1"
|
||||||
|
tiny_http = "0.12.0"
|
||||||
|
thiserror = "1"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = "0.3"
|
||||||
|
once_cell = "1"
|
||||||
|
|
||||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
tauri-plugin-global-shortcut = "2"
|
tauri-plugin-global-shortcut = "2"
|
||||||
|
|||||||
@@ -1,17 +1,39 @@
|
|||||||
use tauri::Manager;
|
use tauri::Manager;
|
||||||
use tauri_plugin_positioner::WindowExt;
|
use tauri_plugin_positioner::WindowExt;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
|
core::services::auth::get_tokens,
|
||||||
get_app_handle,
|
get_app_handle,
|
||||||
services::overlay::{overlay_fullscreen, SCENE_WINDOW_LABEL},
|
services::overlay::{overlay_fullscreen, SCENE_WINDOW_LABEL},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub async fn start_fdoll() {
|
pub async fn start_fdoll() {
|
||||||
initialize_session().await;
|
init_session().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn initialize_session() {
|
pub async fn init_session() {
|
||||||
let webview_window = tauri::WebviewWindowBuilder::new(
|
match get_tokens().await {
|
||||||
|
Some(_) => {
|
||||||
|
info!("User session restored");
|
||||||
|
create_scene().await;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
info!("No active session, user needs to authenticate");
|
||||||
|
crate::core::services::auth::init_auth_code_retrieval(|| {
|
||||||
|
info!("Authentication successful, creating scene...");
|
||||||
|
tauri::async_runtime::spawn(async {
|
||||||
|
info!("Creating scene after auth success...");
|
||||||
|
create_scene().await;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_scene() {
|
||||||
|
info!("Starting scene creation...");
|
||||||
|
let webview_window = match tauri::WebviewWindowBuilder::new(
|
||||||
get_app_handle(),
|
get_app_handle(),
|
||||||
SCENE_WINDOW_LABEL,
|
SCENE_WINDOW_LABEL,
|
||||||
tauri::WebviewUrl::App("/scene".into()),
|
tauri::WebviewUrl::App("/scene".into()),
|
||||||
@@ -27,19 +49,42 @@ pub async fn initialize_session() {
|
|||||||
.always_on_top(true)
|
.always_on_top(true)
|
||||||
.visible_on_all_workspaces(true)
|
.visible_on_all_workspaces(true)
|
||||||
.build()
|
.build()
|
||||||
.expect("Failed to display scene screen");
|
{
|
||||||
|
Ok(window) => {
|
||||||
|
info!("Scene window builder succeeded");
|
||||||
|
window
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to build scene window: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
webview_window
|
if let Err(e) = webview_window.move_window(tauri_plugin_positioner::Position::Center) {
|
||||||
.move_window(tauri_plugin_positioner::Position::Center)
|
error!("Failed to move scene window to center: {}", e);
|
||||||
.unwrap();
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let window = get_app_handle().get_window(webview_window.label()).unwrap();
|
let window = match get_app_handle().get_window(webview_window.label()) {
|
||||||
overlay_fullscreen(&window).unwrap();
|
Some(window) => window,
|
||||||
window.set_ignore_cursor_events(true).unwrap();
|
None => {
|
||||||
|
error!("Failed to get scene window after creation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(e) = overlay_fullscreen(&window) {
|
||||||
|
error!("Failed to set overlay fullscreen: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = window.set_ignore_cursor_events(true) {
|
||||||
|
error!("Failed to set ignore cursor events: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
webview_window.open_devtools();
|
webview_window.open_devtools();
|
||||||
|
|
||||||
println!("Scene window initialized.");
|
info!("Scene window initialized successfully.");
|
||||||
crate::core::services::auth::get_auth_code();
|
|
||||||
}
|
}
|
||||||
|
|||||||
42
src-tauri/src/assets/auth-success.html
Normal file
42
src-tauri/src/assets/auth-success.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<!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>
|
||||||
@@ -7,6 +7,7 @@ pub struct AuthConfig {
|
|||||||
pub audience: String,
|
pub audience: String,
|
||||||
pub auth_url: String,
|
pub auth_url: String,
|
||||||
pub redirect_uri: String,
|
pub redirect_uri: String,
|
||||||
|
pub redirect_host: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Default, Serialize, Deserialize, Clone, Debug, TS)]
|
#[derive(Default, Serialize, Deserialize, Clone, Debug, TS)]
|
||||||
|
|||||||
@@ -1,10 +1,83 @@
|
|||||||
use crate::{core::state::FDOLL, lock_r, APP_HANDLE};
|
use crate::{core::state::FDOLL, lock_r, lock_w, APP_HANDLE};
|
||||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
|
||||||
|
use keyring::Entry;
|
||||||
use rand::{distr::Alphanumeric, Rng};
|
use rand::{distr::Alphanumeric, Rng};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
|
use thiserror::Error;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use tracing::{error, info, warn};
|
||||||
|
use url::form_urlencoded;
|
||||||
|
|
||||||
/// Generate a random code verifier (PKCE spec: 43 to 128 chars, here defaulting to 64)
|
static REFRESH_LOCK: once_cell::sync::Lazy<Mutex<()>> =
|
||||||
|
once_cell::sync::Lazy::new(|| Mutex::new(()));
|
||||||
|
|
||||||
|
static AUTH_SUCCESS_HTML: &str = include_str!("../../assets/auth-success.html");
|
||||||
|
|
||||||
|
/// Errors that can occur during OAuth authentication flow.
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum OAuthError {
|
||||||
|
#[error("Failed to exchange code: {0}")]
|
||||||
|
ExchangeFailed(String),
|
||||||
|
|
||||||
|
#[error("Invalid callback state - possible CSRF attack")]
|
||||||
|
InvalidState,
|
||||||
|
|
||||||
|
#[error("Missing callback parameter: {0}")]
|
||||||
|
MissingParameter(String),
|
||||||
|
|
||||||
|
#[error("Keyring error: {0}")]
|
||||||
|
KeyringError(#[from] keyring::Error),
|
||||||
|
|
||||||
|
#[error("Network error: {0}")]
|
||||||
|
NetworkError(#[from] reqwest::Error),
|
||||||
|
|
||||||
|
#[error("JSON serialization error: {0}")]
|
||||||
|
SerializationError(#[from] serde_json::Error),
|
||||||
|
|
||||||
|
#[error("Server binding failed: {0}")]
|
||||||
|
ServerBindError(String),
|
||||||
|
|
||||||
|
#[error("Callback timeout - no response received")]
|
||||||
|
CallbackTimeout,
|
||||||
|
|
||||||
|
#[error("Invalid app configuration")]
|
||||||
|
InvalidConfig,
|
||||||
|
|
||||||
|
#[error("Failed to refresh token")]
|
||||||
|
RefreshFailed,
|
||||||
|
|
||||||
|
#[error("OAuth state expired or not initialized")]
|
||||||
|
StateExpired,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters received from the OAuth callback.
|
||||||
|
pub struct OAuthCallbackParams {
|
||||||
|
state: String,
|
||||||
|
session_state: String,
|
||||||
|
iss: String,
|
||||||
|
code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authentication pass containing access token, refresh token, and metadata.
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct AuthPass {
|
||||||
|
pub access_token: String,
|
||||||
|
pub expires_in: u64,
|
||||||
|
pub refresh_expires_in: u64,
|
||||||
|
pub refresh_token: String,
|
||||||
|
pub token_type: String,
|
||||||
|
pub session_state: String,
|
||||||
|
pub scope: String,
|
||||||
|
pub issued_at: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate a random code verifier for PKCE.
|
||||||
|
///
|
||||||
|
/// Per PKCE spec (RFC 7636), the code verifier should be 43-128 characters.
|
||||||
fn generate_code_verifier(length: usize) -> String {
|
fn generate_code_verifier(length: usize) -> String {
|
||||||
rand::rng()
|
rand::rng()
|
||||||
.sample_iter(&Alphanumeric)
|
.sample_iter(&Alphanumeric)
|
||||||
@@ -13,7 +86,9 @@ fn generate_code_verifier(length: usize) -> String {
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate code challenge from a code verifier
|
/// Generate code challenge from a code verifier using SHA-256.
|
||||||
|
///
|
||||||
|
/// This implements the S256 method as specified in RFC 7636.
|
||||||
fn generate_code_challenge(code_verifier: &str) -> String {
|
fn generate_code_challenge(code_verifier: &str) -> String {
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(code_verifier.as_bytes());
|
hasher.update(code_verifier.as_bytes());
|
||||||
@@ -23,44 +98,545 @@ fn generate_code_challenge(code_verifier: &str) -> String {
|
|||||||
|
|
||||||
/// Returns the auth pass object, including
|
/// Returns the auth pass object, including
|
||||||
/// access token, refresh token, expire time etc.
|
/// access token, refresh token, expire time etc.
|
||||||
#[allow(dead_code)]
|
/// Automatically refreshes if expired.
|
||||||
pub fn get_tokens() {
|
pub async fn get_tokens() -> Option<AuthPass> {
|
||||||
todo!();
|
info!("Retrieving tokens");
|
||||||
|
let Some(auth_pass) = ({ lock_r!(FDOLL).auth_pass.clone() }) else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(issued_at) = auth_pass.issued_at else {
|
||||||
|
warn!("Auth pass missing issued_at timestamp, clearing");
|
||||||
|
lock_w!(FDOLL).auth_pass = None;
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
if !expired {
|
||||||
|
return Some(auth_pass);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens the auth portal in the browser,
|
if refresh_expired {
|
||||||
/// and returns auth code after user logged in.
|
info!("Refresh token expired, clearing auth state");
|
||||||
pub fn get_auth_code() {
|
lock_w!(FDOLL).auth_pass = None;
|
||||||
let app_config = lock_r!(FDOLL)
|
if let Err(e) = clear_auth_pass() {
|
||||||
.app_config
|
error!("Failed to clear expired auth pass: {}", e);
|
||||||
.clone()
|
}
|
||||||
.expect("Invalid app config");
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
let opener = APP_HANDLE.get().unwrap().opener();
|
// Use mutex to prevent concurrent refresh
|
||||||
|
let _guard = REFRESH_LOCK.lock().await;
|
||||||
|
|
||||||
|
// Double-check after acquiring lock
|
||||||
|
let auth_pass = lock_r!(FDOLL).auth_pass.clone()?;
|
||||||
|
let current_time = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs();
|
||||||
|
let expired = current_time - auth_pass.issued_at? >= auth_pass.expires_in;
|
||||||
|
|
||||||
|
if !expired {
|
||||||
|
// Another thread already refreshed
|
||||||
|
return Some(auth_pass);
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Access token expired, attempting refresh");
|
||||||
|
match refresh_token(&auth_pass.refresh_token).await {
|
||||||
|
Ok(new_pass) => Some(new_pass),
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to refresh token: {}", e);
|
||||||
|
lock_w!(FDOLL).auth_pass = None;
|
||||||
|
if let Err(e) = clear_auth_pass() {
|
||||||
|
error!("Failed to clear auth pass after refresh failure: {}", e);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper function to get the current access token.
|
||||||
|
pub async fn get_access_token() -> Option<String> {
|
||||||
|
get_tokens().await.map(|pass| pass.access_token)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save auth_pass to secure storage (keyring) and update app state.
|
||||||
|
pub fn save_auth_pass(auth_pass: &AuthPass) -> Result<(), OAuthError> {
|
||||||
|
let entry = Entry::new("friendolls", "auth_pass")?;
|
||||||
|
let json = serde_json::to_string(auth_pass)?;
|
||||||
|
entry.set_password(&json)?;
|
||||||
|
info!("Auth pass saved to keyring successfully");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load auth_pass from secure storage (keyring).
|
||||||
|
pub fn load_auth_pass() -> Result<Option<AuthPass>, OAuthError> {
|
||||||
|
info!("Reading credentials from keyring");
|
||||||
|
let entry = match Entry::new("friendolls", "auth_pass") {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to open keyring entry");
|
||||||
|
panic!()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
info!("Opened credentials from keyring");
|
||||||
|
match entry.get_password() {
|
||||||
|
Ok(json) => {
|
||||||
|
info!("Got credentials from keyring");
|
||||||
|
let auth_pass: AuthPass = match serde_json::from_str(&json) {
|
||||||
|
Ok(v) => {
|
||||||
|
info!("Deserialized auth pass from keyring");
|
||||||
|
v
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to decode auth pass from keyring");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
info!("Auth pass loaded from keyring");
|
||||||
|
Ok(Some(auth_pass))
|
||||||
|
}
|
||||||
|
Err(keyring::Error::NoEntry) => {
|
||||||
|
info!("No auth pass found in keyring");
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to load from keyring");
|
||||||
|
Err(OAuthError::KeyringError(e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear auth_pass from secure storage and app state.
|
||||||
|
pub fn clear_auth_pass() -> Result<(), OAuthError> {
|
||||||
|
let entry = Entry::new("friendolls", "auth_pass")?;
|
||||||
|
match entry.delete_credential() {
|
||||||
|
Ok(_) => {
|
||||||
|
info!("Auth pass cleared from keyring successfully");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(keyring::Error::NoEntry) => {
|
||||||
|
info!("Auth pass already cleared from keyring");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Err(e) => Err(OAuthError::KeyringError(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Logout the current user by clearing tokens from storage and state.
|
||||||
|
///
|
||||||
|
/// # Note
|
||||||
|
///
|
||||||
|
/// This currently only clears local tokens. For complete logout, you should also
|
||||||
|
/// call the OAuth provider's token revocation endpoint if available.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use crate::core::services::auth::logout;
|
||||||
|
///
|
||||||
|
/// logout().expect("Failed to logout");
|
||||||
|
/// ```
|
||||||
|
pub fn logout() -> Result<(), OAuthError> {
|
||||||
|
info!("Logging out user");
|
||||||
|
lock_w!(FDOLL).auth_pass = None;
|
||||||
|
clear_auth_pass()?;
|
||||||
|
|
||||||
|
// Clear OAuth flow state as well
|
||||||
|
lock_w!(FDOLL).oauth_flow = Default::default();
|
||||||
|
|
||||||
|
// TODO: Call OAuth provider's revocation endpoint
|
||||||
|
// This would require adding a revoke_token() function that calls:
|
||||||
|
// POST {auth_url}/revoke with the refresh_token
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to add authentication header to a request builder if tokens are available.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use crate::core::services::auth::with_auth;
|
||||||
|
///
|
||||||
|
/// let client = reqwest::Client::new();
|
||||||
|
/// let request = client.get("https://api.example.com/user");
|
||||||
|
/// let authenticated_request = with_auth(request).await;
|
||||||
|
/// ```
|
||||||
|
pub async fn with_auth(request: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||||
|
if let Some(token) = get_access_token().await {
|
||||||
|
request.header("Authorization", format!("Bearer {}", token))
|
||||||
|
} else {
|
||||||
|
request
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Exchange authorization code for tokens.
|
||||||
|
///
|
||||||
|
/// This is called after receiving the OAuth callback with an authorization code.
|
||||||
|
/// It exchanges the code for an access token and refresh token.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `callback_params` - Parameters received from the OAuth callback
|
||||||
|
/// * `code_verifier` - The PKCE code verifier that was used to generate the code challenge
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `OAuthError` if the exchange fails or the server returns an error.
|
||||||
|
pub async fn exchange_code_for_auth_pass(
|
||||||
|
callback_params: OAuthCallbackParams,
|
||||||
|
code_verifier: &str,
|
||||||
|
) -> Result<AuthPass, OAuthError> {
|
||||||
|
let (app_config, http_client) = {
|
||||||
|
let guard = lock_r!(FDOLL);
|
||||||
|
(
|
||||||
|
guard.app_config.clone().ok_or(OAuthError::InvalidConfig)?,
|
||||||
|
guard.http_client.clone(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = url::Url::parse(&format!("{}/token", &app_config.auth.auth_url))
|
||||||
|
.map_err(|_| OAuthError::InvalidConfig)?;
|
||||||
|
|
||||||
|
let body = form_urlencoded::Serializer::new(String::new())
|
||||||
|
.append_pair("client_id", &app_config.auth.audience)
|
||||||
|
.append_pair("grant_type", "authorization_code")
|
||||||
|
.append_pair("redirect_uri", &app_config.auth.redirect_uri)
|
||||||
|
.append_pair("code", &callback_params.code)
|
||||||
|
.append_pair("code_verifier", code_verifier)
|
||||||
|
.finish();
|
||||||
|
|
||||||
|
info!("Exchanging authorization code for tokens");
|
||||||
|
|
||||||
|
let exchange_request = http_client
|
||||||
|
.post(url)
|
||||||
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
.body(body);
|
||||||
|
|
||||||
|
let exchange_request_response = exchange_request.send().await?;
|
||||||
|
|
||||||
|
if !exchange_request_response.status().is_success() {
|
||||||
|
let status = exchange_request_response.status();
|
||||||
|
let error_text = exchange_request_response.text().await.unwrap_or_default();
|
||||||
|
error!(
|
||||||
|
"Token exchange failed with status {}: {}",
|
||||||
|
status, error_text
|
||||||
|
);
|
||||||
|
return Err(OAuthError::ExchangeFailed(format!(
|
||||||
|
"Status: {}, Body: {}",
|
||||||
|
status, error_text
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut auth_pass: AuthPass = exchange_request_response.json().await?;
|
||||||
|
auth_pass.issued_at = Some(
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_err(|_| OAuthError::ExchangeFailed("System time error".to_string()))?
|
||||||
|
.as_secs(),
|
||||||
|
);
|
||||||
|
|
||||||
|
info!("Successfully exchanged code for tokens");
|
||||||
|
Ok(auth_pass)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize the OAuth authorization code flow.
|
||||||
|
///
|
||||||
|
/// This function:
|
||||||
|
/// 1. Generates PKCE code verifier and challenge
|
||||||
|
/// 2. Generates state parameter for CSRF protection
|
||||||
|
/// 3. Stores state and code verifier in app state
|
||||||
|
/// 4. Opens the OAuth authorization URL in the user's browser
|
||||||
|
/// 5. Starts a background listener for the callback
|
||||||
|
///
|
||||||
|
/// The user will be redirected to the OAuth provider's login page, and after
|
||||||
|
/// successful authentication, will be redirected back to the local callback server.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
///
|
||||||
|
/// ```rust,no_run
|
||||||
|
/// use crate::core::services::auth::init_auth_code_retrieval;
|
||||||
|
///
|
||||||
|
/// init_auth_code_retrieval();
|
||||||
|
/// // User will be prompted to login in their browser
|
||||||
|
/// ```
|
||||||
|
pub fn init_auth_code_retrieval<F>(on_success: F)
|
||||||
|
where
|
||||||
|
F: FnOnce() + Send + 'static,
|
||||||
|
{
|
||||||
|
let app_config = match lock_r!(FDOLL).app_config.clone() {
|
||||||
|
Some(config) => config,
|
||||||
|
None => {
|
||||||
|
error!("Cannot initialize auth: app config not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let opener = match APP_HANDLE.get() {
|
||||||
|
Some(handle) => handle.opener(),
|
||||||
|
None => {
|
||||||
|
error!("Cannot initialize auth: app handle not available");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let code_verifier = generate_code_verifier(64);
|
let code_verifier = generate_code_verifier(64);
|
||||||
let code_challenge = generate_code_challenge(&code_verifier);
|
let code_challenge = generate_code_challenge(&code_verifier);
|
||||||
let state = generate_code_verifier(16);
|
let state = generate_code_verifier(16);
|
||||||
|
|
||||||
let mut url = url::Url::parse(&app_config.auth.auth_url.as_str()).expect("Invalid app config");
|
// Store state and code_verifier for validation
|
||||||
|
let current_time = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
{
|
||||||
|
let mut guard = lock_w!(FDOLL);
|
||||||
|
guard.oauth_flow.state = Some(state.clone());
|
||||||
|
guard.oauth_flow.code_verifier = Some(code_verifier.clone());
|
||||||
|
guard.oauth_flow.initiated_at = Some(current_time);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut url = match url::Url::parse(&format!("{}/auth", &app_config.auth.auth_url)) {
|
||||||
|
Ok(url) => url,
|
||||||
|
Err(e) => {
|
||||||
|
error!("Invalid auth URL configuration: {}", e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
url.query_pairs_mut()
|
url.query_pairs_mut()
|
||||||
.append_pair("client_id", &app_config.auth.audience.as_str())
|
.append_pair("client_id", &app_config.auth.audience)
|
||||||
.append_pair("response_type", "code")
|
.append_pair("response_type", "code")
|
||||||
.append_pair("redirect_uri", &app_config.auth.redirect_uri.as_str())
|
.append_pair("redirect_uri", &app_config.auth.redirect_uri)
|
||||||
.append_pair("scope", "openid email profile")
|
.append_pair("scope", "openid email profile")
|
||||||
.append_pair("state", &state)
|
.append_pair("state", &state)
|
||||||
.append_pair("code_challenge", &code_challenge)
|
.append_pair("code_challenge", &code_challenge)
|
||||||
.append_pair("code_challenge_method", "S256");
|
.append_pair("code_challenge_method", "S256");
|
||||||
|
|
||||||
match opener.open_url(url, None::<&str>) {
|
info!("Initiating OAuth flow");
|
||||||
Ok(_) => (),
|
|
||||||
Err(e) => panic!("Failed to open auth portal: {}", e),
|
thread::spawn(move || {
|
||||||
|
tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
.unwrap()
|
||||||
|
.block_on(async move {
|
||||||
|
match listen_for_callback().await {
|
||||||
|
Ok(callback_params) => {
|
||||||
|
// Validate state
|
||||||
|
let stored_state = lock_r!(FDOLL).oauth_flow.state.clone();
|
||||||
|
|
||||||
|
if stored_state.as_ref() != Some(&callback_params.state) {
|
||||||
|
error!("State mismatch - possible CSRF attack!");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve code_verifier
|
||||||
|
let code_verifier = match lock_r!(FDOLL).oauth_flow.code_verifier.clone() {
|
||||||
|
Some(cv) => cv,
|
||||||
|
None => {
|
||||||
|
error!("Code verifier not found in state");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Clear OAuth flow state after successful callback
|
||||||
|
lock_w!(FDOLL).oauth_flow = Default::default();
|
||||||
|
|
||||||
|
match exchange_code_for_auth_pass(callback_params, &code_verifier).await {
|
||||||
|
Ok(auth_pass) => {
|
||||||
|
lock_w!(FDOLL).auth_pass = Some(auth_pass.clone());
|
||||||
|
if let Err(e) = save_auth_pass(&auth_pass) {
|
||||||
|
error!("Failed to save auth pass: {}", e);
|
||||||
|
} else {
|
||||||
|
info!("Authentication successful!");
|
||||||
|
on_success();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to exchange code for tokens: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
error!("Failed to receive callback: {}", e);
|
||||||
|
// Clear OAuth flow state on error
|
||||||
|
lock_w!(FDOLL).oauth_flow = Default::default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Err(e) = opener.open_url(url, None::<&str>) {
|
||||||
|
error!("Failed to open auth portal: {}", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accepts a refresh token and
|
/// Refresh the access token using a refresh token.
|
||||||
/// returns a new access token.
|
///
|
||||||
#[allow(dead_code)]
|
/// This is called automatically by `get_tokens()` when the access token is expired
|
||||||
pub fn refresh_token() {
|
/// but the refresh token is still valid.
|
||||||
todo!();
|
///
|
||||||
|
/// # Arguments
|
||||||
|
///
|
||||||
|
/// * `refresh_token` - The refresh token to use
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `OAuthError::RefreshFailed` if the refresh fails.
|
||||||
|
pub async fn refresh_token(refresh_token: &str) -> Result<AuthPass, OAuthError> {
|
||||||
|
let (app_config, http_client) = {
|
||||||
|
let guard = lock_r!(FDOLL);
|
||||||
|
(
|
||||||
|
guard.app_config.clone().ok_or(OAuthError::InvalidConfig)?,
|
||||||
|
guard.http_client.clone(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = url::Url::parse(&format!("{}/token", &app_config.auth.auth_url))
|
||||||
|
.map_err(|_| OAuthError::InvalidConfig)?;
|
||||||
|
|
||||||
|
let body = form_urlencoded::Serializer::new(String::new())
|
||||||
|
.append_pair("client_id", &app_config.auth.audience)
|
||||||
|
.append_pair("grant_type", "refresh_token")
|
||||||
|
.append_pair("refresh_token", refresh_token)
|
||||||
|
.finish();
|
||||||
|
|
||||||
|
info!("Refreshing access token");
|
||||||
|
|
||||||
|
let refresh_request = http_client
|
||||||
|
.post(url)
|
||||||
|
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
.body(body);
|
||||||
|
|
||||||
|
let refresh_response = refresh_request.send().await?;
|
||||||
|
|
||||||
|
if !refresh_response.status().is_success() {
|
||||||
|
let status = refresh_response.status();
|
||||||
|
let error_text = refresh_response.text().await.unwrap_or_default();
|
||||||
|
error!(
|
||||||
|
"Token refresh failed with status {}: {}",
|
||||||
|
status, error_text
|
||||||
|
);
|
||||||
|
return Err(OAuthError::RefreshFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut auth_pass: AuthPass = refresh_response.json().await?;
|
||||||
|
auth_pass.issued_at = Some(
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map_err(|_| OAuthError::RefreshFailed)?
|
||||||
|
.as_secs(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update state and storage
|
||||||
|
lock_w!(FDOLL).auth_pass = Some(auth_pass.clone());
|
||||||
|
if let Err(e) = save_auth_pass(&auth_pass) {
|
||||||
|
error!("Failed to save refreshed auth pass: {}", e);
|
||||||
|
} else {
|
||||||
|
info!("Token refreshed successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(auth_pass)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a local HTTP server to listen for the OAuth callback.
|
||||||
|
///
|
||||||
|
/// This function starts a mini web server that listens on the configured redirect host
|
||||||
|
/// for the OAuth callback. It:
|
||||||
|
/// - Listens on the `/callback` endpoint
|
||||||
|
/// - Validates all required parameters are present
|
||||||
|
/// - Returns a nice HTML page to the user
|
||||||
|
/// - Has a 5-minute timeout to prevent hanging indefinitely
|
||||||
|
/// - Also provides a `/health` endpoint for health checks
|
||||||
|
///
|
||||||
|
/// # Timeout
|
||||||
|
///
|
||||||
|
/// The server will timeout after 5 minutes if no callback is received,
|
||||||
|
/// preventing the server from running indefinitely if the user abandons the flow.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `OAuthError` if:
|
||||||
|
/// - Server fails to bind to the configured port
|
||||||
|
/// - Required callback parameters are missing
|
||||||
|
/// - Timeout is reached before callback is received
|
||||||
|
async fn listen_for_callback() -> Result<OAuthCallbackParams, OAuthError> {
|
||||||
|
let app_config = lock_r!(FDOLL)
|
||||||
|
.app_config
|
||||||
|
.clone()
|
||||||
|
.ok_or(OAuthError::InvalidConfig)?;
|
||||||
|
|
||||||
|
let server = tiny_http::Server::http(&app_config.auth.redirect_host)
|
||||||
|
.map_err(|e| OAuthError::ServerBindError(e.to_string()))?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"Listening on {} for /callback",
|
||||||
|
&app_config.auth.redirect_host
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set a 5-minute timeout
|
||||||
|
let timeout = Duration::from_secs(300);
|
||||||
|
let start_time = SystemTime::now();
|
||||||
|
|
||||||
|
for request in server.incoming_requests() {
|
||||||
|
// Check timeout
|
||||||
|
if SystemTime::now()
|
||||||
|
.duration_since(start_time)
|
||||||
|
.unwrap_or(Duration::ZERO)
|
||||||
|
> timeout
|
||||||
|
{
|
||||||
|
warn!("Callback listener timed out after 5 minutes");
|
||||||
|
return Err(OAuthError::CallbackTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = request.url().to_string();
|
||||||
|
|
||||||
|
if url.starts_with("/callback") {
|
||||||
|
let query = url.split('?').nth(1).unwrap_or("");
|
||||||
|
let params = form_urlencoded::parse(query.as_bytes())
|
||||||
|
.map(|(k, v)| (k.into_owned(), v.into_owned()))
|
||||||
|
.collect::<Vec<(String, String)>>();
|
||||||
|
|
||||||
|
info!("Received OAuth callback");
|
||||||
|
|
||||||
|
let find_param = |key: &str| -> Result<String, OAuthError> {
|
||||||
|
params
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _)| k == key)
|
||||||
|
.map(|(_, v)| v.clone())
|
||||||
|
.ok_or_else(|| OAuthError::MissingParameter(key.to_string()))
|
||||||
|
};
|
||||||
|
|
||||||
|
let callback_params = OAuthCallbackParams {
|
||||||
|
state: find_param("state")?,
|
||||||
|
session_state: find_param("session_state")?,
|
||||||
|
iss: find_param("iss")?,
|
||||||
|
code: find_param("code")?,
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = tiny_http::Response::from_string(AUTH_SUCCESS_HTML).with_header(
|
||||||
|
tiny_http::Header::from_bytes(
|
||||||
|
&b"Content-Type"[..],
|
||||||
|
&b"text/html; charset=utf-8"[..],
|
||||||
|
)
|
||||||
|
.map_err(|_| OAuthError::ServerBindError("Header creation failed".to_string()))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = request.respond(response);
|
||||||
|
|
||||||
|
info!("Callback processed, stopping listener");
|
||||||
|
return Ok(callback_params);
|
||||||
|
} else if url == "/health" {
|
||||||
|
// Health check endpoint
|
||||||
|
let _ = request.respond(tiny_http::Response::from_string("OK"));
|
||||||
|
} else {
|
||||||
|
let _ = request.respond(tiny_http::Response::empty(404));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(OAuthError::CallbackTimeout)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// in app-core/src/state.rs
|
// in app-core/src/state.rs
|
||||||
use crate::{
|
use crate::{
|
||||||
core::models::app_config::{AppConfig, AuthConfig},
|
core::models::app_config::{AppConfig, AuthConfig},
|
||||||
|
core::services::auth::{load_auth_pass, AuthPass},
|
||||||
lock_w,
|
lock_w,
|
||||||
};
|
};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
@@ -8,11 +9,21 @@ use std::{
|
|||||||
env,
|
env,
|
||||||
sync::{Arc, LazyLock, RwLock},
|
sync::{Arc, LazyLock, RwLock},
|
||||||
};
|
};
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
#[derive(Default, Clone)]
|
||||||
|
pub struct OAuthFlowTracker {
|
||||||
|
pub state: Option<String>,
|
||||||
|
pub code_verifier: Option<String>,
|
||||||
|
pub initiated_at: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub app_config: Option<AppConfig>,
|
pub app_config: Option<AppConfig>,
|
||||||
pub http_client: Client,
|
pub http_client: Client,
|
||||||
|
pub auth_pass: Option<AuthPass>,
|
||||||
|
pub oauth_flow: OAuthFlowTracker,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global application state
|
// Global application state
|
||||||
@@ -30,8 +41,16 @@ pub fn init_fdoll_state() {
|
|||||||
audience: env::var("JWT_AUDIENCE").expect("JWT_AUDIENCE must be set"),
|
audience: env::var("JWT_AUDIENCE").expect("JWT_AUDIENCE must be set"),
|
||||||
auth_url: env::var("AUTH_URL").expect("AUTH_URL must be set"),
|
auth_url: env::var("AUTH_URL").expect("AUTH_URL must be set"),
|
||||||
redirect_uri: env::var("REDIRECT_URI").expect("REDIRECT_URI must be set"),
|
redirect_uri: env::var("REDIRECT_URI").expect("REDIRECT_URI must be set"),
|
||||||
|
redirect_host: env::var("REDIRECT_HOST").expect("REDIRECT_HOST must be set"),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
guard.auth_pass = match load_auth_pass() {
|
||||||
|
Ok(pass) => pass,
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Failed to load auth pass from keyring: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
guard.http_client = reqwest::ClientBuilder::new()
|
guard.http_client = reqwest::ClientBuilder::new()
|
||||||
.redirect(reqwest::redirect::Policy::none())
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
.build()
|
.build()
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
use crate::services::cursor::channel_cursor_positions;
|
use crate::services::cursor::channel_cursor_positions;
|
||||||
|
use tauri::async_runtime;
|
||||||
|
use tracing_subscriber;
|
||||||
|
|
||||||
static APP_HANDLE: std::sync::OnceLock<tauri::AppHandle<tauri::Wry>> = std::sync::OnceLock::new();
|
static APP_HANDLE: std::sync::OnceLock<tauri::AppHandle<tauri::Wry>> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
@@ -14,8 +16,16 @@ pub fn get_app_handle<'a>() -> &'a tauri::AppHandle<tauri::Wry> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn setup_fdoll() -> Result<(), tauri::Error> {
|
fn setup_fdoll() -> Result<(), tauri::Error> {
|
||||||
|
// Initialize tracing subscriber for logging
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_target(false)
|
||||||
|
.with_thread_ids(false)
|
||||||
|
.with_file(true)
|
||||||
|
.with_line_number(true)
|
||||||
|
.init();
|
||||||
|
|
||||||
core::state::init_fdoll_state();
|
core::state::init_fdoll_state();
|
||||||
tokio::spawn(async move { app::start_fdoll().await });
|
async_runtime::spawn(async move { app::start_fdoll().await });
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,19 @@
|
|||||||
use crate::get_app_handle;
|
use crate::get_app_handle;
|
||||||
|
|
||||||
pub static SCENE_WINDOW_LABEL: &str = "scene";
|
pub static SCENE_WINDOW_LABEL: &str = "scene";
|
||||||
|
|
||||||
pub fn overlay_fullscreen(window: &tauri::Window) -> Result<(), tauri::Error> {
|
pub fn overlay_fullscreen(window: &tauri::Window) -> Result<(), tauri::Error> {
|
||||||
// Get the primary monitor
|
// Get the primary monitor
|
||||||
let monitor = get_app_handle().primary_monitor()?.unwrap();
|
let monitor = get_app_handle().primary_monitor()?.unwrap();
|
||||||
let monitor_position = monitor.position();
|
// Get the work area (usable space, excluding menu bar/dock/notch)
|
||||||
let monitor_size = monitor.size();
|
let work_area = monitor.work_area();
|
||||||
|
// Set window position to top-left of the work area
|
||||||
// Set window position to top-left
|
|
||||||
window.set_position(tauri::PhysicalPosition {
|
window.set_position(tauri::PhysicalPosition {
|
||||||
x: monitor_position.x,
|
x: work_area.position.x,
|
||||||
y: monitor_position.y,
|
y: work_area.position.y,
|
||||||
})?;
|
})?;
|
||||||
|
// Set window size to match work area size
|
||||||
// Set window size to match screen size
|
|
||||||
window.set_size(tauri::PhysicalSize {
|
window.set_size(tauri::PhysicalSize {
|
||||||
width: monitor_size.width,
|
width: work_area.size.width,
|
||||||
height: monitor_size.height,
|
height: work_area.size.height,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user