mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-03 09:47:21 +00:00
Added a session cookie for the app window
This commit is contained in:
4 files changed
+166
-29
No files matched your search
@@ -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<APIToken> = 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())));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ use tauri_plugin_updater::{UpdaterExt, Update};
|
|||||||
use tauri_plugin_opener::OpenerExt;
|
use tauri_plugin_opener::OpenerExt;
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
use tokio::time;
|
use tokio::time;
|
||||||
|
use crate::app_access::hand_over_session_cookie;
|
||||||
use crate::clipboard::shutdown_clipboard;
|
use crate::clipboard::shutdown_clipboard;
|
||||||
use crate::data_restore::apply_pending_restore;
|
use crate::data_restore::apply_pending_restore;
|
||||||
use crate::dotnet::{cleanup_dotnet_server, start_dotnet_server, stop_dotnet_server};
|
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) {
|
pub async fn change_location_to(url: tauri::Url) {
|
||||||
// Try to get the main window. If it is not available yet, wait for it:
|
// Try to get the main window. If it is not available yet, wait for it:
|
||||||
let mut main_window_ready = false;
|
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
|
// 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
|
// 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,
|
// another site, and the window would withhold the session cookie. We take the window out of
|
||||||
// which may wait for this lock meanwhile.
|
// 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 main_window = main_window_spawn_clone.lock().unwrap().clone().unwrap();
|
||||||
let location_change_result = main_window.navigate(url.clone());
|
let navigation = tokio::task::spawn_blocking(move || {
|
||||||
match location_change_result {
|
hand_over_session_cookie(&main_window, &url);
|
||||||
Ok(_) => info!("The app location was changed to {url}."),
|
match main_window.navigate(url.clone()) {
|
||||||
Err(e) => error!("Failed to change the app location to {url}: {e}."),
|
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}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+42
-22
@@ -9,6 +9,7 @@ use tauri::{Manager, Url};
|
|||||||
use tauri_plugin_shell::process::{CommandChild, CommandEvent, TerminatedPayload};
|
use tauri_plugin_shell::process::{CommandChild, CommandEvent, TerminatedPayload};
|
||||||
use tauri_plugin_shell::ShellExt;
|
use tauri_plugin_shell::ShellExt;
|
||||||
use crate::runtime_api_token::API_TOKEN;
|
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::app_window::change_location_to;
|
||||||
use crate::runtime_certificate::CERTIFICATE_FINGERPRINT;
|
use crate::runtime_certificate::CERTIFICATE_FINGERPRINT;
|
||||||
use crate::encryption::ENCRYPTION;
|
use crate::encryption::ENCRYPTION;
|
||||||
@@ -177,46 +178,43 @@ fn dotnet_bundle_extraction_directory<R: tauri::Runtime>(app_handle: &tauri::App
|
|||||||
/// but also remove that code from any production build.
|
/// but also remove that code from any production build.
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
pub fn create_startup_env_file() {
|
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'.");
|
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 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 = std::fs::File::create(env_file_path).unwrap();
|
||||||
let mut env_file_lines = vec![
|
let env_file_lines: Vec<String> = ipc_environment()
|
||||||
format!("AI_STUDIO_SECRET_PASSWORD={secret_password}"),
|
.into_iter()
|
||||||
format!("AI_STUDIO_SECRET_KEY_SALT={secret_key_salt}"),
|
.map(|(key, value)| format!("{key}={value}"))
|
||||||
format!("AI_STUDIO_CERTIFICATE_FINGERPRINT={}", CERTIFICATE_FINGERPRINT.get().unwrap()),
|
.collect();
|
||||||
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}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
std::io::Write::write_all(&mut env_file, env_file_lines.join("\n").as_bytes()).unwrap();
|
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.");
|
info!(Source = "Bootloader .NET"; "The startup env file was created successfully.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Starts the .NET server in a separate process.
|
/// Returns the environment which the .NET server needs to talk to this runtime and to let only
|
||||||
pub fn start_dotnet_server<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>) {
|
/// 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:
|
// Get the secret password & salt and convert it to a base64 string:
|
||||||
let secret_password = BASE64_STANDARD.encode(ENCRYPTION.secret_password);
|
let secret_password = BASE64_STANDARD.encode(ENCRYPTION.secret_password);
|
||||||
let secret_key_salt = BASE64_STANDARD.encode(ENCRYPTION.secret_key_salt);
|
let secret_key_salt = BASE64_STANDARD.encode(ENCRYPTION.secret_key_salt);
|
||||||
let api_port = *API_SERVER_PORT;
|
let api_port = *API_SERVER_PORT;
|
||||||
|
|
||||||
let mut dotnet_server_environment: HashMap<String, String> = HashMap::from_iter([
|
let mut environment = vec![
|
||||||
(String::from("AI_STUDIO_SECRET_PASSWORD"), secret_password),
|
(String::from("AI_STUDIO_SECRET_PASSWORD"), secret_password),
|
||||||
(String::from("AI_STUDIO_SECRET_KEY_SALT"), secret_key_salt),
|
(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_CERTIFICATE_FINGERPRINT"), CERTIFICATE_FINGERPRINT.get().unwrap().to_string()),
|
||||||
(String::from("AI_STUDIO_API_PORT"), format!("{api_port}")),
|
(String::from("AI_STUDIO_API_PORT"), format!("{api_port}")),
|
||||||
(String::from("AI_STUDIO_API_TOKEN"), API_TOKEN.to_hex_text().to_string()),
|
(String::from("AI_STUDIO_API_TOKEN"), API_TOKEN.to_hex_text().to_string()),
|
||||||
]);
|
(String::from(DOTNET_ENV_UI_TOKEN), UI_ACCESS_TOKEN.to_hex_text().to_string()),
|
||||||
dotnet_server_environment.extend(external_http_custom_root_certificate_policy_environment());
|
];
|
||||||
|
environment.extend(external_http_custom_root_certificate_policy_environment());
|
||||||
|
environment
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the .NET server in a separate process.
|
||||||
|
pub fn start_dotnet_server<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>) {
|
||||||
|
let mut dotnet_server_environment: HashMap<String, String> = HashMap::from_iter(ipc_environment());
|
||||||
if let Some(extraction_directory) = dotnet_bundle_extraction_directory(&app_handle) {
|
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);
|
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
|
/// address only, so the window goes there directly instead of resolving `localhost`, which may
|
||||||
/// answer with the IPv6 loopback address first.
|
/// answer with the IPv6 loopback address first.
|
||||||
fn app_address(port: u16) -> String {
|
fn app_address(port: u16) -> String {
|
||||||
format!("http://127.0.0.1:{port}/")
|
format!("http://{APP_HOST}:{port}/")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stops the .NET server process.
|
/// Stops the .NET server process.
|
||||||
@@ -364,4 +362,26 @@ mod tests {
|
|||||||
let url = Url::parse(app_address(61153).as_str()).unwrap();
|
let url = Url::parse(app_address(61153).as_str()).unwrap();
|
||||||
assert!(is_local_http_url(&url));
|
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()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ pub mod environment;
|
|||||||
pub mod dotnet;
|
pub mod dotnet;
|
||||||
pub mod network;
|
pub mod network;
|
||||||
pub mod api_token;
|
pub mod api_token;
|
||||||
|
pub mod app_access;
|
||||||
pub mod app_window;
|
pub mod app_window;
|
||||||
pub mod secret;
|
pub mod secret;
|
||||||
pub mod clipboard;
|
pub mod clipboard;
|
||||||
|
|||||||
Reference in new issue
Block a user