mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-11-01 22:07:20 +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 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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+42
-22
@@ -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<R: tauri::Runtime>(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<String> = 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<R: tauri::Runtime>(app_handle: tauri::AppHandle<R>) {
|
||||
/// 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<String, String> = 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<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) {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user