Added a session cookie for the app window

This commit is contained in:
Thorsten Sommer committed 2026-10-31 19:13:03 +01:00
1 parent 2a5da9fce1
commit 97f3f97679
4 files changed
+166 -29

No files matched your search

+104
View File
@@ -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())));
}
}
+19 -7
View File
@@ -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
View File
@@ -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()));
}
}
+1
View File
@@ -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;