//! Per-process RPC bearer-token authentication. //! //! At server startup, [`init_rpc_token`] either reads the token from the //! `OPENHUMAN_CORE_TOKEN` environment variable (Tauri-spawned path) or //! generates a 256-bit cryptographically-random token and writes it to //! `{workspace_dir}/core.token` (owner-read-only on Unix, standalone CLI path), //! then stores it in a process-global [`OnceLock`]. //! //! **Tauri path**: the Tauri shell generates the token in //! `CoreProcessHandle::new()`, injects it as `OPENHUMAN_CORE_TOKEN` before //! spawning the core process, and holds it in memory via //! `CoreProcessHandle.rpc_token`. The shell includes the token in every //! request as `Authorization: Bearer `. The `core.token` file is //! never written in this path. //! //! **Standalone CLI path**: the core generates a fresh token and writes it to //! `{workspace_dir}/core.token` so that CLI clients can read and use it. //! //! Endpoints exempt from auth (checked by [`rpc_auth_middleware`]): //! - `GET /` — public info page //! - `GET /health` — liveness probe //! - `GET /auth/telegram` — external browser callback (carries its own token) //! - `GET /schema` — read-only schema discovery //! - `GET /events` — SSE stream; browser `EventSource` cannot set headers //! - `GET /events/webhooks` — webhook SSE; same browser constraint //! - `GET /ws/dictation` — WebSocket upgrade; browser WS API cannot set headers //! - `OPTIONS *` — CORS preflight (handled by outer CORS middleware) //! //! Only `POST /rpc` carries executable commands and requires the bearer token. use std::io::Write as _; use std::path::Path; use std::sync::OnceLock; #[cfg(unix)] use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _}; use axum::http::{header, Method, StatusCode}; use axum::middleware::Next; use axum::response::{IntoResponse, Response}; use axum::Json; use serde_json::json; static RPC_TOKEN: OnceLock = OnceLock::new(); /// Paths that bypass bearer-token authentication. /// /// Only `/rpc` carries executable commands and must be protected. All other /// routes are read-only, streaming, or WebSocket upgrades whose clients /// (browser `EventSource`, browser `WebSocket`) cannot set `Authorization` /// headers via standard APIs. const PUBLIC_PATHS: &[&str] = &[ "/", "/health", "/auth/telegram", "/schema", "/events", "/events/webhooks", "/ws/dictation", ]; /// The environment variable the Tauri shell sets before spawning the core. /// /// When this variable is present the core uses its value as the RPC token /// (no file I/O needed). When absent (standalone `openhuman core run`) the /// core generates a token and writes it to `{workspace_dir}/core.token` so /// CLI clients can authenticate. pub const CORE_TOKEN_ENV_VAR: &str = "OPENHUMAN_CORE_TOKEN"; /// Initialize the per-process RPC token. /// /// **Preferred path — Tauri-spawned core**: reads the token from the /// `OPENHUMAN_CORE_TOKEN` environment variable set by the Tauri shell. No /// file is written; the token is always available the instant the process /// starts. /// /// **Fallback — standalone CLI**: generates a fresh 256-bit token, writes it /// to `{workspace_dir}/core.token` (owner-read-only on Unix) for external /// callers, and stores it in the process global. /// /// # Errors /// /// Returns an error only in the fallback path, if the token file cannot be /// written. pub fn init_rpc_token(workspace_dir: &Path) -> anyhow::Result<()> { // Idempotency guard: if the token is already set, do nothing. A second // call must never write a new token to disk while the process still // validates the original in-memory value — that would cause clients // reading core.token to start getting 401s immediately. if RPC_TOKEN.get().is_some() { log::debug!("[auth] init_rpc_token: already initialized, skipping"); return Ok(()); } // Fast path: token pre-seeded by the Tauri shell via env var. if let Ok(env_token) = std::env::var(CORE_TOKEN_ENV_VAR) { let env_token = env_token.trim().to_string(); if !env_token.is_empty() { let _ = RPC_TOKEN.set(env_token); log::info!("[auth] core RPC token loaded from environment (Tauri-managed)"); return Ok(()); } } // Fallback: standalone CLI — generate and write to file. let token = generate_token(); let token_path = workspace_dir.join("core.token"); write_token_file(&token_path, &token)?; let _ = RPC_TOKEN.set(token); log::info!( "[auth] core RPC token generated and written to {}", token_path.display() ); Ok(()) } /// Returns the active RPC token, if initialized. pub fn get_rpc_token() -> Option<&'static str> { RPC_TOKEN.get().map(String::as_str) } /// Axum middleware: enforce `Authorization: Bearer ` on all protected /// endpoints. /// /// Public paths (see [`PUBLIC_PATHS`]) and CORS preflight `OPTIONS` requests /// bypass this check. All other requests must carry the exact bearer token /// that was written to `core.token` at startup. pub async fn rpc_auth_middleware(req: axum::extract::Request, next: Next) -> Response { let path = req.uri().path().to_string(); // CORS preflight and public utility paths bypass auth. if req.method() == Method::OPTIONS || PUBLIC_PATHS.contains(&path.as_str()) { return next.run(req).await; } let Some(expected) = get_rpc_token() else { // Shouldn't happen in production — token is always initialized before // the router starts serving. Deny to be safe. log::error!("[auth] RPC token not initialized — denying request to {path}"); return ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({ "ok": false, "error": "server_error", "message": "Auth subsystem not initialized" })), ) .into_response(); }; let bearer = req .headers() .get(header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) .unwrap_or(""); if bearer .strip_prefix("Bearer ") .is_some_and(|token| token == expected) { log::trace!("[auth] authorized request to {path}"); next.run(req).await } else { log::warn!("[auth] unauthorized request to {path} — missing or wrong bearer token"); ( StatusCode::UNAUTHORIZED, Json(json!({ "ok": false, "error": "unauthorized", "message": "Missing or invalid Authorization header. Supply 'Authorization: Bearer '." })), ) .into_response() } } /// Generate a 256-bit cryptographically-random token as a lowercase hex string. /// /// Uses `rand::rng()` (thread-local, OS-seeded CSPRNG) introduced in rand 0.9. fn generate_token() -> String { use rand::RngCore as _; let mut bytes = [0u8; 32]; rand::rng().fill_bytes(&mut bytes); hex::encode(bytes) } /// Write `token` to `path` with owner-only read+write permissions on Unix. fn write_token_file(path: &Path, token: &str) -> anyhow::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } #[cfg(unix)] { let mut file = std::fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .mode(0o600) .open(path)?; file.write_all(token.as_bytes())?; } #[cfg(not(unix))] { std::fs::write(path, token)?; } Ok(()) } #[cfg(test)] mod tests { use super::*; #[test] fn generate_token_produces_64_hex_chars() { let t = generate_token(); assert_eq!(t.len(), 64, "256 bits → 64 hex chars"); assert!(t.chars().all(|c| c.is_ascii_hexdigit()), "must be hex"); } #[test] fn generate_token_is_not_constant() { assert_ne!(generate_token(), generate_token()); } #[test] fn write_and_read_token_roundtrips() { let tmp = std::env::temp_dir().join(format!("core-auth-test-{}", std::process::id())); std::fs::create_dir_all(&tmp).unwrap(); let path = tmp.join("core.token"); let token = "cafebabe1234567890abcdef0123456789abcdef0123456789abcdef01234567"; write_token_file(&path, token).unwrap(); let back = std::fs::read_to_string(&path).unwrap(); assert_eq!(back, token); std::fs::remove_dir_all(&tmp).ok(); } #[cfg(unix)] #[test] fn token_file_has_owner_only_permissions() { let tmp = std::env::temp_dir().join(format!("core-auth-perms-{}", std::process::id())); std::fs::create_dir_all(&tmp).unwrap(); let path = tmp.join("core.token"); write_token_file(&path, "abc").unwrap(); let mode = std::fs::metadata(&path).unwrap().permissions().mode(); assert_eq!(mode & 0o777, 0o600, "token file must be 0o600"); std::fs::remove_dir_all(&tmp).ok(); } }