diff --git a/packages/electron-app/electron/main/main.ts b/packages/electron-app/electron/main/main.ts index 13e1d7a7..55e3dbad 100644 --- a/packages/electron-app/electron/main/main.ts +++ b/packages/electron-app/electron/main/main.ts @@ -328,7 +328,6 @@ function finalizeCliSwap(url: string) { mainWindow.loadURL(url).catch((error) => console.error("[cli] failed to load CLI view:", error)) } -const SESSION_COOKIE_NAME = "codenomad_session" let bootstrapExchangeInFlight = false function extractCookieValue(setCookieHeader: string | string[] | undefined, name: string): string | null { @@ -351,6 +350,7 @@ function extractCookieValue(setCookieHeader: string | string[] | undefined, name } async function exchangeBootstrapToken(baseUrl: string, token: string): Promise { + const sessionCookieName = cliManager.getAuthCookieName() const target = new URL("/api/auth/token", baseUrl) const body = JSON.stringify({ token }) @@ -381,14 +381,14 @@ async function exchangeBootstrapToken(baseUrl: string, token: string): Promise { @@ -139,6 +141,7 @@ export class CliProcessManager extends EventEmitter { this.stdoutBuffer = "" this.stderrBuffer = "" this.bootstrapToken = null + this.authCookieName = `${SESSION_COOKIE_NAME_PREFIX}_${process.pid}_${Date.now()}` this.requestedStop = false this.updateStatus({ state: "starting", port: undefined, pid: undefined, url: undefined, error: undefined }) @@ -436,6 +439,10 @@ export class CliProcessManager extends EventEmitter { return { ...this.status } } + getAuthCookieName(): string { + return this.authCookieName + } + private resolveListeningMode(): ListeningMode { return readListeningModeFromConfig() } @@ -532,7 +539,7 @@ export class CliProcessManager extends EventEmitter { } private buildCliArgs(options: StartOptions, host: string): string[] { - const args = ["serve", "--host", host, "--generate-token"] + const args = ["serve", "--host", host, "--generate-token", "--auth-cookie-name", this.authCookieName] if (options.dev) { // Dev: run plain HTTP + Vite dev server proxy. diff --git a/packages/server/src/auth/manager.ts b/packages/server/src/auth/manager.ts index dde0f72f..f12b8761 100644 --- a/packages/server/src/auth/manager.ts +++ b/packages/server/src/auth/manager.ts @@ -16,16 +16,18 @@ export interface AuthManagerInit { password?: string generateToken: boolean dangerouslySkipAuth?: boolean + cookieName?: string } export class AuthManager { private readonly authStore: AuthStore | null private readonly tokenManager: TokenManager | null private readonly sessionManager = new SessionManager() - private readonly cookieName = DEFAULT_AUTH_COOKIE_NAME + private readonly cookieName: string private readonly authEnabled: boolean constructor(private readonly init: AuthManagerInit, private readonly logger: Logger) { + this.cookieName = sanitizeCookieName(init.cookieName) this.authEnabled = !Boolean(init.dangerouslySkipAuth) 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) { const resolvedConfigPath = resolvePath(configPath) return path.join(path.dirname(resolvedConfigPath), "auth.json") diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 1d83ec72..c20a64b2 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -19,7 +19,7 @@ import { InstanceEventBridge } from "./workspaces/instance-events" import { createLogger } from "./logger" import { launchInBrowser } from "./launcher" 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 { resolveNetworkAddresses } from "./server/network-addresses" import { startDevReleaseMonitor } from "./releases/dev-release-monitor" @@ -55,6 +55,7 @@ interface CliOptions { launch: boolean authUsername: string authPassword?: string + authCookieName: string generateToken: boolean dangerouslySkipAuth: boolean } @@ -100,6 +101,11 @@ function parseCliOptions(argv: string[]): CliOptions { .default(DEFAULT_AUTH_USERNAME), ) .addOption(new Option("--password ", "Password for server authentication").env("CODENOMAD_SERVER_PASSWORD")) + .addOption( + new Option("--auth-cookie-name ", "Cookie name for server authentication") + .env("CODENOMAD_AUTH_COOKIE_NAME") + .default(DEFAULT_AUTH_COOKIE_NAME), + ) .addOption( new Option("--generate-token", "Emit a one-time bootstrap token for desktop") .env("CODENOMAD_GENERATE_TOKEN") @@ -139,6 +145,7 @@ function parseCliOptions(argv: string[]): CliOptions { launch?: boolean username: string password?: string + authCookieName: string generateToken?: boolean dangerouslySkipAuth?: boolean }>() @@ -185,6 +192,7 @@ function parseCliOptions(argv: string[]): CliOptions { launch: Boolean(parsed.launch), authUsername: parsed.username, authPassword: parsed.password, + authCookieName: parsed.authCookieName, generateToken: Boolean(parsed.generateToken), dangerouslySkipAuth: Boolean(parsed.dangerouslySkipAuth), } @@ -266,6 +274,7 @@ async function main() { configPath: configLocation.configYamlPath, username: options.authUsername, password: options.authPassword, + cookieName: options.authCookieName, generateToken: options.generateToken, dangerouslySkipAuth: options.dangerouslySkipAuth, }, diff --git a/packages/tauri-app/src-tauri/src/cli_manager.rs b/packages/tauri-app/src-tauri/src/cli_manager.rs index 27b94d54..20bb522d 100644 --- a/packages/tauri-app/src-tauri/src/cli_manager.rs +++ b/packages/tauri-app/src-tauri/src/cli_manager.rs @@ -16,7 +16,7 @@ use std::process::{Child, Command, Stdio}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; 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}; #[cfg(windows)] @@ -48,7 +48,7 @@ fn workspace_root() -> Option { }) } -const SESSION_COOKIE_NAME: &str = "codenomad_session"; +const SESSION_COOKIE_NAME_PREFIX: &str = "codenomad_session"; const CLI_STOP_GRACE_SECS: u64 = 30; #[cfg(windows)] @@ -124,7 +124,11 @@ fn extract_cookie_value(set_cookie: &str, name: &str) -> Option { Some(value.to_string()) } -fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result> { +fn exchange_bootstrap_token( + base_url: &str, + token: &str, + cookie_name: &str, +) -> anyhow::Result> { let parsed = Url::parse(base_url)?; let host = parsed.host_str().unwrap_or("127.0.0.1"); let port = parsed.port_or_known_default().unwrap_or(80); @@ -159,11 +163,11 @@ fn exchange_bootstrap_token(base_url: &str, token: &str) -> anyhow::Result anyhow::Result 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 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) .path("/") .http_only(true) @@ -190,6 +199,16 @@ fn set_session_cookie(app: &AppHandle, base_url: &str, session_id: &str) -> anyh 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"; #[derive(Debug, Deserialize)] @@ -503,7 +522,8 @@ impl CliProcessManager { "resolved CLI entry runner={:?} entry={} host={}", resolution.runner, resolution.entry, host )); - let args = resolution.build_args(dev, &host); + let auth_cookie_name = Arc::new(generate_auth_cookie_name()); + let args = resolution.build_args(dev, &host, auth_cookie_name.as_str()); log_line(&format!("CLI args: {:?}", args)); if dev { log_line("development mode: will prefer tsx + source if present"); @@ -584,6 +604,7 @@ impl CliProcessManager { let app_clone = app.clone(); let ready_clone = ready.clone(); let token_clone = bootstrap_token.clone(); + let auth_cookie_name_clone = auth_cookie_name.clone(); thread::spawn(move || { let stdout = child_clone @@ -605,6 +626,7 @@ impl CliProcessManager { &status_clone, &ready_clone, &token_clone, + auth_cookie_name_clone.as_str(), ); } if let Some(reader) = stderr { @@ -615,6 +637,7 @@ impl CliProcessManager { &status_clone, &ready_clone, &token_clone, + auth_cookie_name_clone.as_str(), ); } }); @@ -731,6 +754,7 @@ impl CliProcessManager { status: &Arc>, ready: &Arc, bootstrap_token: &Arc>>, + auth_cookie_name: &str, ) { let mut buffer = String::new(); let local_url_regex = Regex::new(r"^Local\s+Connection\s+URL\s*:\s*(https?://\S+)").ok(); @@ -766,7 +790,14 @@ impl CliProcessManager { .and_then(|re| re.captures(line).and_then(|c| c.get(1))) .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; } @@ -781,6 +812,7 @@ impl CliProcessManager { status, ready, bootstrap_token, + auth_cookie_name, format!("http://localhost:{port}"), ); continue; @@ -793,6 +825,7 @@ impl CliProcessManager { status, ready, bootstrap_token, + auth_cookie_name, format!("http://localhost:{}", port), ); continue; @@ -811,6 +844,7 @@ impl CliProcessManager { status: &Arc>, ready: &Arc, bootstrap_token: &Arc>>, + auth_cookie_name: &str, base_url: String, ) { ready.store(true, Ordering::SeqCst); @@ -834,9 +868,11 @@ impl CliProcessManager { if scheme.as_deref() != Some("http") { navigate_main(app, &base_url); } else { - match exchange_bootstrap_token(&base_url, &token) { + match exchange_bootstrap_token(&base_url, &token, &auth_cookie_name) { 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}")); navigate_main(app, &format!("{base_url}/login")); } else { @@ -932,11 +968,13 @@ impl CliEntry { )) } - fn build_args(&self, dev: bool, host: &str) -> Vec { + fn build_args(&self, dev: bool, host: &str, auth_cookie_name: &str) -> Vec { let mut args = vec![ "serve".to_string(), "--host".to_string(), host.to_string(), + "--auth-cookie-name".to_string(), + auth_cookie_name.to_string(), "--generate-token".to_string(), ];