fix(tauri): isolate desktop auth cookies per app

This commit is contained in:
Pascal André
2026-03-14 13:37:57 +01:00
parent d7ab84f245
commit 034cb5dea9
3 changed files with 78 additions and 14 deletions

View File

@@ -16,16 +16,18 @@ export interface AuthManagerInit {
password?: string password?: string
generateToken: boolean generateToken: boolean
dangerouslySkipAuth?: boolean dangerouslySkipAuth?: boolean
cookieName?: string
} }
export class AuthManager { export class AuthManager {
private readonly authStore: AuthStore | null private readonly authStore: AuthStore | null
private readonly tokenManager: TokenManager | null private readonly tokenManager: TokenManager | null
private readonly sessionManager = new SessionManager() private readonly sessionManager = new SessionManager()
private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME private readonly cookieName: string
private readonly authEnabled: boolean private readonly authEnabled: boolean
constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) { constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) {
this.cookieName = sanitizeCookieName(init.cookieName)
this.authEnabled = !Boolean(init.dangerouslySkipAuth) this.authEnabled = !Boolean(init.dangerouslySkipAuth)
if (!this.authEnabled) { if (!this.authEnabled) {
@@ -139,6 +141,16 @@ export class AuthManager {
} }
} }
function sanitizeCookieName(value: string | undefined): string {
const trimmed = value?.trim()
if (!trimmed) {
return DEFAULT_AUTH_COOKIE_NAME
}
const sanitized = trimmed.replace(/[^A-Za-z0-9_-]/g, "_")
return sanitized.length > 0 ? sanitized : DEFAULT_AUTH_COOKIE_NAME
}
function resolveAuthFilePath(configPath: string) { function resolveAuthFilePath(configPath: string) {
const resolvedConfigPath = resolvePath(configPath) const resolvedConfigPath = resolvePath(configPath)
return path.join(path.dirname(resolvedConfigPath), "auth.json") return path.join(path.dirname(resolvedConfigPath), "auth.json")

View File

@@ -19,7 +19,7 @@ import { InstanceEventBridge } from "./workspaces/instance-events"
import { createLogger } from "./logger" import { createLogger } from "./logger"
import { launchInBrowser } from "./launcher" import { launchInBrowser } from "./launcher"
import { resolveUi } from "./ui/remote-ui" import { resolveUi } from "./ui/remote-ui"
import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_USERNAME } from "./auth/manager" import { AuthManager, BOOTSTRAP_TOKEN_STDOUT_PREFIX, DEFAULT_AUTH_COOKIE_NAME, DEFAULT_AUTH_USERNAME } from "./auth/manager"
import { resolveHttpsOptions } from "./server/tls" import { resolveHttpsOptions } from "./server/tls"
import { resolveNetworkAddresses } from "./server/network-addresses" import { resolveNetworkAddresses } from "./server/network-addresses"
import { startDevReleaseMonitor } from "./releases/dev-release-monitor" import { startDevReleaseMonitor } from "./releases/dev-release-monitor"
@@ -54,6 +54,7 @@ interface CliOptions {
launch: boolean launch: boolean
authUsername: string authUsername: string
authPassword?: string authPassword?: string
authCookieName: string
generateToken: boolean generateToken: boolean
dangerouslySkipAuth: boolean dangerouslySkipAuth: boolean
} }
@@ -99,6 +100,11 @@ function parseCliOptions(argv: string[]): CliOptions {
.default(DEFAULT_AUTH_USERNAME), .default(DEFAULT_AUTH_USERNAME),
) )
.addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD")) .addOption(new Option("--password <password>", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD"))
.addOption(
new Option("--auth-cookie-name <name>", "Cookie name for server authentication")
.env("CODENOMAD_AUTH_COOKIE_NAME")
.default(DEFAULT_AUTH_COOKIE_NAME),
)
.addOption( .addOption(
new Option("--generate-token", "Emit a one-time bootstrap token for desktop") new Option("--generate-token", "Emit a one-time bootstrap token for desktop")
.env("CODENOMAD_GENERATE_TOKEN") .env("CODENOMAD_GENERATE_TOKEN")
@@ -138,6 +144,7 @@ function parseCliOptions(argv: string[]): CliOptions {
launch?: boolean launch?: boolean
username: string username: string
password?: string password?: string
authCookieName: string
generateToken?: boolean generateToken?: boolean
dangerouslySkipAuth?: boolean dangerouslySkipAuth?: boolean
}>() }>()
@@ -184,6 +191,7 @@ function parseCliOptions(argv: string[]): CliOptions {
launch: Boolean(parsed.launch), launch: Boolean(parsed.launch),
authUsername: parsed.username, authUsername: parsed.username,
authPassword: parsed.password, authPassword: parsed.password,
authCookieName: parsed.authCookieName,
generateToken: Boolean(parsed.generateToken), generateToken: Boolean(parsed.generateToken),
dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth), dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth),
} }
@@ -265,6 +273,7 @@ async function main() {
configPath: configLocation.configYamlPath, configPath: configLocation.configYamlPath,
username: options.authUsername, username: options.authUsername,
password: options.authPassword, password: options.authPassword,
cookieName: options.authCookieName,
generateToken: options.generateToken, generateToken: options.generateToken,
dangerouslySkipAuth: options.dangerouslySkipAuth, dangerouslySkipAuth: options.dangerouslySkipAuth,
}, },

View File

@@ -14,7 +14,7 @@ use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use std::thread; use std::thread;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url}; use tauri::{webview::cookie::Cookie, AppHandle, Emitter, Manager, Url};
fn log_line(message: &str) { fn log_line(message: &str) {
@@ -32,7 +32,7 @@ fn workspace_root() -> Option<PathBuf> {
}) })
} }
const SESSION_COOKIE_NAME: &str = "codenomad_session"; const SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session";
const CLI_STOP_GRACE_SECS: u64 = 30; const CLI_STOP_GRACE_SECS: u64 = 30;
@@ -66,7 +66,11 @@ fn extract_cookie_value(set_cookie: &str, name: &str) -> Option<String> {
Some(value.to_string()) Some(value.to_string())
} }
fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Option<String>> { fn exchange_bootstrap_token(
base_url: &str,
token: &str,
cookie_name: &str,
) -> anyhow::Result<Option<String>> {
let parsed = Url::parse(base_url)?; let parsed = Url::parse(base_url)?;
let host = parsed.host_str().unwrap_or("127.0.0.1"); let host = parsed.host_str().unwrap_or("127.0.0.1");
let port = parsed.port_or_known_default().unwrap_or(80); let port = parsed.port_or_known_default().unwrap_or(80);
@@ -101,11 +105,11 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Optio
for line in lines { for line in lines {
// handle case-insensitive header name // handle case-insensitive header name
if let Some(value) = line.strip_prefix("Set-Cookie:") { if let Some(value) = line.strip_prefix("Set-Cookie:") {
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) { if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
return Ok(Some(session_id)); return Ok(Some(session_id));
} }
} else if let Some(value) = line.strip_prefix("set-cookie:") { } else if let Some(value) = line.strip_prefix("set-cookie:") {
if let Some(session_id) = extract_cookie_value(value.trim(), SESSION_COOKIE_NAME) { if let Some(session_id) = extract_cookie_value(value.trim(), cookie_name) {
return Ok(Some(session_id)); return Ok(Some(session_id));
} }
} }
@@ -114,11 +118,16 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result<Optio
Ok(None) Ok(None)
} }
fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyhow::Result<()> { fn set_session_cookie(
app: &AppHandle,
base_url: &str,
cookie_name: &str,
session_id: &str,
) -> anyhow::Result<()> {
let parsed = Url::parse(base_url)?; let parsed = Url::parse(base_url)?;
let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string(); let domain = parsed.host_str().unwrap_or("127.0.0.1").to_string();
let cookie = Cookie::build((SESSION_COOKIE_NAME, session_id)) let cookie = Cookie::build((cookie_name.to_string(), session_id.to_string()))
.domain(domain) .domain(domain)
.path("/") .path("/")
.http_only(true) .http_only(true)
@@ -132,6 +141,16 @@ fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyh
Ok(()) Ok(())
} }
fn generate_auth_cookie_name() -> String {
let pid = std::process::id();
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis())
.unwrap_or(0);
format!("{SESSION_COOKIE_NAME_PREFIX}_{pid}_{timestamp}")
}
const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json"; const DEFAULT_CONFIG_PATH: &str = "~/.config/codenomad/config.json";
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -412,7 +431,13 @@ impl CliProcessManager {
"resolved CLI entry runner={:?} entry={} host={}", "resolved CLI entry runner={:?} entry={} host={}",
resolution.runner, resolution.entry, host resolution.runner, resolution.entry, host
)); ));
let args = resolution.build_args(dev, &host); let auth_cookie_name = Arc::new(generate_auth_cookie_name());
emit_perf_startup(
&app,
"cli.auth.cookie.generated",
json!({ "cookieName": auth_cookie_name }),
);
let args = resolution.build_args(dev, &host, auth_cookie_name.as_str());
log_line(&format!("CLI args: {:?}", args)); log_line(&format!("CLI args: {:?}", args));
if dev { if dev {
log_line("development mode: will prefer tsx + source if present"); log_line("development mode: will prefer tsx + source if present");
@@ -487,6 +512,7 @@ impl CliProcessManager {
let app_clone = app.clone(); let app_clone = app.clone();
let ready_clone = ready.clone(); let ready_clone = ready.clone();
let token_clone = bootstrap_token.clone(); let token_clone = bootstrap_token.clone();
let auth_cookie_name_clone = auth_cookie_name.clone();
thread::spawn(move || { thread::spawn(move || {
let stdout = child_clone let stdout = child_clone
@@ -508,6 +534,7 @@ impl CliProcessManager {
&status_clone, &status_clone,
&ready_clone, &ready_clone,
&token_clone, &token_clone,
auth_cookie_name_clone.as_str(),
); );
} }
if let Some(reader) = stderr { if let Some(reader) = stderr {
@@ -518,6 +545,7 @@ impl CliProcessManager {
&status_clone, &status_clone,
&ready_clone, &ready_clone,
&token_clone, &token_clone,
auth_cookie_name_clone.as_str(),
); );
} }
}); });
@@ -617,6 +645,7 @@ impl CliProcessManager {
status: &Arc<Mutex<CliStatus>>, status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>, ready: &Arc<AtomicBool>,
bootstrap_token: &Arc<Mutex<Option<String>>>, bootstrap_token: &Arc<Mutex<Option<String>>>,
auth_cookie_name: &str,
) { ) {
let mut buffer = String::new(); let mut buffer = String::new();
let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok(); let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok();
@@ -652,7 +681,14 @@ impl CliProcessManager {
.and_then(|re| re.captures(line).and_then(|c| c.get(1))) .and_then(|re| re.captures(line).and_then(|c| c.get(1)))
.map(|m| m.as_str().to_string()) .map(|m| m.as_str().to_string())
{ {
Self::mark_ready(app, status, ready, bootstrap_token, url); Self::mark_ready(
app,
status,
ready,
bootstrap_token,
auth_cookie_name,
url,
);
continue; continue;
} }
@@ -667,6 +703,7 @@ impl CliProcessManager {
status, status,
ready, ready,
bootstrap_token, bootstrap_token,
auth_cookie_name,
format!("http://localhost:{port}"), format!("http://localhost:{port}"),
); );
continue; continue;
@@ -679,6 +716,7 @@ impl CliProcessManager {
status, status,
ready, ready,
bootstrap_token, bootstrap_token,
auth_cookie_name,
format!("http://localhost:{}", port), format!("http://localhost:{}", port),
); );
continue; continue;
@@ -697,6 +735,7 @@ impl CliProcessManager {
status: &Arc<Mutex<CliStatus>>, status: &Arc<Mutex<CliStatus>>,
ready: &Arc<AtomicBool>, ready: &Arc<AtomicBool>,
bootstrap_token: &Arc<Mutex<Option<String>>>, bootstrap_token: &Arc<Mutex<Option<String>>>,
auth_cookie_name: &str,
base_url: String, base_url: String,
) { ) {
ready.store(true, Ordering::SeqCst); ready.store(true, Ordering::SeqCst);
@@ -720,9 +759,11 @@ impl CliProcessManager {
if scheme.as_deref() != Some("http") { if scheme.as_deref() != Some("http") {
navigate_main(app, &base_url); navigate_main(app, &base_url);
} else { } else {
match exchange_bootstrap_token(&base_url, &token) { match exchange_bootstrap_token(&base_url, &token, &auth_cookie_name) {
Ok(Some(session_id)) => { Ok(Some(session_id)) => {
if let Err(err) = set_session_cookie(app, &base_url, &session_id) { if let Err(err) =
set_session_cookie(app, &base_url, &auth_cookie_name, &session_id)
{
log_line(&format!("failed to set session cookie: {err}")); log_line(&format!("failed to set session cookie: {err}"));
navigate_main(app, &format!("{base_url}/login")); navigate_main(app, &format!("{base_url}/login"));
} else { } else {
@@ -818,11 +859,13 @@ impl CliEntry {
)) ))
} }
fn build_args(&self, dev: bool, host: &str) -> Vec<String> { fn build_args(&self, dev: bool, host: &str, auth_cookie_name: &str) -> Vec<String> {
let mut args = vec![ let mut args = vec![
"serve".to_string(), "serve".to_string(),
"--host".to_string(), "--host".to_string(),
host.to_string(), host.to_string(),
"--auth-cookie-name".to_string(),
auth_cookie_name.to_string(),
"--generate-token".to_string(), "--generate-token".to_string(),
]; ];