From 97f3f97679dbd95a0543fbfc1a0e8e6c087a5fdf Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sat, 31 Oct 2026 19:13:03 +0100 Subject: [PATCH] Added a session cookie for the app window --- runtime/src/app_access.rs | 104 ++++++++++++++++++++++++++++++++++++++ runtime/src/app_window.rs | 26 +++++++--- runtime/src/dotnet.rs | 64 +++++++++++++++-------- runtime/src/lib.rs | 1 + 4 files changed, 166 insertions(+), 29 deletions(-) create mode 100644 runtime/src/app_access.rs diff --git a/runtime/src/app_access.rs b/runtime/src/app_access.rs new file mode 100644 index 000000000..b0bea703d --- /dev/null +++ b/runtime/src/app_access.rs @@ -0,0 +1,104 @@ +use log::{error, info}; +use once_cell::sync::Lazy; +use tauri::webview::Cookie; +use tauri::{Url, WebviewWindow}; +use crate::api_token::{generate_api_token, APIToken}; + +/// The token which the app window presents to the .NET server as the value of its session cookie. +/// Every start makes a new one. +pub static UI_ACCESS_TOKEN: Lazy = Lazy::new(generate_api_token); + +/// The name of the session cookie. The .NET server expects the same name. +pub const SESSION_COOKIE_NAME: &str = "ai_studio_session"; + +/// The environment variable which carries the token to the .NET server. +pub const DOTNET_ENV_UI_TOKEN: &str = "AI_STUDIO_UI_TOKEN"; + +/// The host of the app. The .NET server listens on this address only, and the session cookie +/// belongs to it. +pub const APP_HOST: &str = "127.0.0.1"; + +/// Builds the session cookie of the app window. +/// +/// The cookie is for the app host alone and lives as long as the window: it has no expiry date, +/// scripts cannot read it, and the window never sends it with a request which another site +/// started. We parse it from text because the `cookie` crate, whose builder would need its +/// `SameSite` type, is no direct dependency of ours. +pub fn session_cookie() -> Cookie<'static> { + let token = UI_ACCESS_TOKEN.to_hex_text(); + Cookie::parse(format!("{SESSION_COOKIE_NAME}={token}; Domain={APP_HOST}; Path=/; HttpOnly; SameSite=Strict")) + .expect("The session cookie consists of fixed attributes and a hexadecimal token, which always parse.") +} + +/// Puts the session cookie into the cookie store of the given window, before the window opens +/// the app at the given URL. +/// +/// Setting the cookie does not wait for the webview, and WebView2 even adds it in the background. +/// Reading the cookies back waits until the webview has handled everything sent before, so the +/// cookie is in place once this function returns. That reading is also the only way to learn +/// whether setting the cookie worked. +/// +/// Blocks until the main thread has answered, so never call it on a worker of the async runtime. +/// The log never shows the value of the cookie. +pub fn hand_over_session_cookie(window: &WebviewWindow, app_url: &Url) { + if let Err(error) = window.set_cookie(session_cookie()) { + error!(Source = "App access"; "Failed to hand the session cookie to the app window: {error}"); + return; + } + + match window.cookies_for_url(app_url.clone()) { + Ok(cookies) if cookies.iter().any(|cookie| cookie.name() == SESSION_COOKIE_NAME) => { + info!(Source = "App access"; "The app window got its session cookie."); + }, + + Ok(_) => { + error!(Source = "App access"; "The app window lacks its session cookie after it was set."); + }, + + Err(error) => { + error!(Source = "App access"; "Failed to check the session cookie of the app window: {error}"); + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_session_cookie_carries_the_token() { + let cookie = session_cookie(); + + assert_eq!(cookie.name(), SESSION_COOKIE_NAME); + assert_eq!(cookie.value(), UI_ACCESS_TOKEN.to_hex_text()); + } + + #[test] + fn the_session_cookie_belongs_to_the_app_host_alone() { + let cookie = session_cookie(); + + assert_eq!(cookie.domain(), Some(APP_HOST)); + assert_eq!(cookie.path(), Some("/")); + } + + #[test] + fn the_session_cookie_is_hidden_from_scripts_and_other_sites() { + let cookie = session_cookie(); + + assert_eq!(cookie.http_only(), Some(true)); + assert!(cookie.same_site().is_some_and(|same_site| same_site.is_strict())); + } + + #[test] + fn the_session_cookie_lives_only_as_long_as_the_window() { + let cookie = session_cookie(); + + assert!(cookie.expires().is_none()); + assert!(cookie.max_age().is_none()); + } + + #[test] + fn the_ui_token_differs_from_the_api_token() { + assert!(!UI_ACCESS_TOKEN.validate(&APIToken::from_hex_text(crate::runtime_api_token::API_TOKEN.to_hex_text()))); + } +} \ No newline at end of file diff --git a/runtime/src/app_window.rs b/runtime/src/app_window.rs index e6611dc30..17bbe1067 100644 --- a/runtime/src/app_window.rs +++ b/runtime/src/app_window.rs @@ -21,6 +21,7 @@ use tauri_plugin_updater::{UpdaterExt, Update}; use tauri_plugin_opener::OpenerExt; use tokio::sync::broadcast; use tokio::time; +use crate::app_access::hand_over_session_cookie; use crate::clipboard::shutdown_clipboard; use crate::data_restore::apply_pending_restore; use crate::dotnet::{cleanup_dotnet_server, start_dotnet_server, stop_dotnet_server}; @@ -677,7 +678,8 @@ pub fn send_event(event: Event) { } } -/// Changes the location of the main window to the given URL. +/// Changes the location of the main window to the given URL. The window gets its session cookie +/// first, so that the .NET server lets it in. pub async fn change_location_to(url: tauri::Url) { // Try to get the main window. If it is not available yet, wait for it: let mut main_window_ready = false; @@ -707,14 +709,24 @@ pub async fn change_location_to(url: tauri::Url) { // // The window navigates by itself instead of through a script of the page it shows. That page // comes from Tauri, which is another site than the app, so the app would count as opened by - // another site. We take the window out of the lock first: navigating needs the main thread, - // which may wait for this lock meanwhile. + // another site, and the window would withhold the session cookie. We take the window out of + // the lock first: the cookie and the navigation need the main thread, which may wait for this + // lock meanwhile. + // + // Handing over the cookie waits for the main thread. This function serves a request of the + // runtime API, so the waiting happens outside the async runtime, see runtime/AGENTS.md. // let main_window = main_window_spawn_clone.lock().unwrap().clone().unwrap(); - let location_change_result = main_window.navigate(url.clone()); - match location_change_result { - Ok(_) => info!("The app location was changed to {url}."), - Err(e) => error!("Failed to change the app location to {url}: {e}."), + let navigation = tokio::task::spawn_blocking(move || { + hand_over_session_cookie(&main_window, &url); + match main_window.navigate(url.clone()) { + Ok(_) => info!("The app location was changed to {url}."), + Err(e) => error!("Failed to change the app location to {url}: {e}."), + } + }); + + if let Err(error) = navigation.await { + error!("Failed to open the app in the main window: {error}"); } } diff --git a/runtime/src/dotnet.rs b/runtime/src/dotnet.rs index 48d0d888d..0bb71483a 100644 --- a/runtime/src/dotnet.rs +++ b/runtime/src/dotnet.rs @@ -9,6 +9,7 @@ use tauri::{Manager, Url}; use tauri_plugin_shell::process::{CommandChild, CommandEvent, TerminatedPayload}; use tauri_plugin_shell::ShellExt; use crate::runtime_api_token::API_TOKEN; +use crate::app_access::{APP_HOST, DOTNET_ENV_UI_TOKEN, UI_ACCESS_TOKEN}; use crate::app_window::change_location_to; use crate::runtime_certificate::CERTIFICATE_FINGERPRINT; use crate::encryption::ENCRYPTION; @@ -177,46 +178,43 @@ fn dotnet_bundle_extraction_directory(app_handle: &tauri::App /// but also remove that code from any production build. #[cfg(debug_assertions)] pub fn create_startup_env_file() { - - // Get the secret password & salt and convert it to a base64 string: - let secret_password = BASE64_STANDARD.encode(ENCRYPTION.secret_password); - let secret_key_salt = BASE64_STANDARD.encode(ENCRYPTION.secret_key_salt); - let api_port = *API_SERVER_PORT; - warn!(Source = "Bootloader .NET"; "Development environment detected; create the startup env file at '../startup.env'."); let env_file_path = std::path::PathBuf::from("..").join("startup.env"); let mut env_file = std::fs::File::create(env_file_path).unwrap(); - let mut env_file_lines = vec![ - format!("AI_STUDIO_SECRET_PASSWORD={secret_password}"), - format!("AI_STUDIO_SECRET_KEY_SALT={secret_key_salt}"), - format!("AI_STUDIO_CERTIFICATE_FINGERPRINT={}", CERTIFICATE_FINGERPRINT.get().unwrap()), - format!("AI_STUDIO_API_PORT={api_port}"), - format!("AI_STUDIO_API_TOKEN={}", API_TOKEN.to_hex_text()), - ]; - for (key, value) in external_http_custom_root_certificate_policy_environment() { - env_file_lines.push(format!("{key}={value}")); - } + let env_file_lines: Vec = ipc_environment() + .into_iter() + .map(|(key, value)| format!("{key}={value}")) + .collect(); std::io::Write::write_all(&mut env_file, env_file_lines.join("\n").as_bytes()).unwrap(); info!(Source = "Bootloader .NET"; "The startup env file was created successfully."); } -/// Starts the .NET server in a separate process. -pub fn start_dotnet_server(app_handle: tauri::AppHandle) { +/// Returns the environment which the .NET server needs to talk to this runtime and to let only +/// the app window in. The sidecar gets it, and so does the startup env file in the development +/// environment, so neither of them can miss a variable. +fn ipc_environment() -> Vec<(String, String)> { // Get the secret password & salt and convert it to a base64 string: let secret_password = BASE64_STANDARD.encode(ENCRYPTION.secret_password); let secret_key_salt = BASE64_STANDARD.encode(ENCRYPTION.secret_key_salt); let api_port = *API_SERVER_PORT; - let mut dotnet_server_environment: HashMap = HashMap::from_iter([ + let mut environment = vec![ (String::from("AI_STUDIO_SECRET_PASSWORD"), secret_password), (String::from("AI_STUDIO_SECRET_KEY_SALT"), secret_key_salt), (String::from("AI_STUDIO_CERTIFICATE_FINGERPRINT"), CERTIFICATE_FINGERPRINT.get().unwrap().to_string()), (String::from("AI_STUDIO_API_PORT"), format!("{api_port}")), (String::from("AI_STUDIO_API_TOKEN"), API_TOKEN.to_hex_text().to_string()), - ]); - dotnet_server_environment.extend(external_http_custom_root_certificate_policy_environment()); + (String::from(DOTNET_ENV_UI_TOKEN), UI_ACCESS_TOKEN.to_hex_text().to_string()), + ]; + environment.extend(external_http_custom_root_certificate_policy_environment()); + environment +} + +/// Starts the .NET server in a separate process. +pub fn start_dotnet_server(app_handle: tauri::AppHandle) { + let mut dotnet_server_environment: HashMap = HashMap::from_iter(ipc_environment()); if let Some(extraction_directory) = dotnet_bundle_extraction_directory(&app_handle) { dotnet_server_environment.insert(String::from(DOTNET_ENV_BUNDLE_EXTRACT_BASE_DIR), extraction_directory); } @@ -319,7 +317,7 @@ pub async fn dotnet_ready() { /// address only, so the window goes there directly instead of resolving `localhost`, which may /// answer with the IPv6 loopback address first. fn app_address(port: u16) -> String { - format!("http://127.0.0.1:{port}/") + format!("http://{APP_HOST}:{port}/") } /// Stops the .NET server process. @@ -364,4 +362,26 @@ mod tests { let url = Url::parse(app_address(61153).as_str()).unwrap(); assert!(is_local_http_url(&url)); } + + #[test] + fn the_ipc_environment_carries_every_variable_the_dotnet_server_needs() { + // Only the start of the app generates the certificate this fingerprint belongs to: + let _ = CERTIFICATE_FINGERPRINT.set(String::from("test-fingerprint")); + + let environment = ipc_environment(); + let keys: Vec<&str> = environment.iter().map(|(key, _)| key.as_str()).collect(); + for expected in [ + "AI_STUDIO_SECRET_PASSWORD", + "AI_STUDIO_SECRET_KEY_SALT", + "AI_STUDIO_CERTIFICATE_FINGERPRINT", + "AI_STUDIO_API_PORT", + "AI_STUDIO_API_TOKEN", + DOTNET_ENV_UI_TOKEN, + ] { + assert!(keys.contains(&expected), "The environment lacks {expected}."); + } + + let ui_token = environment.iter().find(|(key, _)| key == DOTNET_ENV_UI_TOKEN).map(|(_, value)| value.as_str()); + assert_eq!(ui_token, Some(UI_ACCESS_TOKEN.to_hex_text())); + } } \ No newline at end of file diff --git a/runtime/src/lib.rs b/runtime/src/lib.rs index 8560a48d0..895bbc5ba 100644 --- a/runtime/src/lib.rs +++ b/runtime/src/lib.rs @@ -4,6 +4,7 @@ pub mod environment; pub mod dotnet; pub mod network; pub mod api_token; +pub mod app_access; pub mod app_window; pub mod secret; pub mod clipboard;