//! Per-process RPC bearer-token authentication. //! //! Three initialization paths feed the process-global [`OnceLock`] that holds //! the active bearer token: //! //! 1. **In-memory handoff (preferred for the in-process core)** — //! [`init_rpc_token_with_value`] sets the token directly from a value the //! Tauri shell already holds in `CoreProcessHandle.rpc_token`. No env var //! is read or set; the token never crosses a process-global env surface. //! This is the path the Tauri host uses now that the core runs in-process //! (PR #1061) — same-process handoff makes the env crossing unnecessary, //! and avoiding it keeps the token off `/proc//environ` (Linux) and //! out of `sysctl KERN_PROCARGS2` / `ps eww -p ` (macOS) where any //! same-UID process could read it without entitlement. //! 2. **Env-as-config fallback** — when no in-memory token is supplied, //! [`init_rpc_token`] reads `OPENHUMAN_CORE_TOKEN` from the environment. //! This is the legitimate operator-supplied transport for Docker / cloud / //! VPS deployments where the bearer must come from `fly secrets set …`, //! `docker run -e …`, or a systemd unit file — there is no live shell //! handing it to the binary in-memory. //! 3. **Standalone CLI fallback** — when neither path supplies a token, the //! core generates a fresh 256-bit token and writes it to //! `{workspace_dir}/core.token` (owner-read-only on Unix) so external CLI //! clients can authenticate. //! //! Once set, the in-memory `OnceLock` is the single source of truth — all //! transports ([`rpc_auth_middleware`], Socket.IO, SSE query-token fallback, //! the approval-gate session id) read via [`get_rpc_token`]. //! //! Endpoints exempt from auth (checked by [`rpc_auth_middleware`]): //! - `GET /` — public info page //! - `GET /health` — liveness probe //! - `GET /auth` — desktop login callback fallback; consumes only //! one-time login tokens, never raw session JWTs //! - `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, so the handler enforces a bind-token / //! bearer credential itself //! - `GET /ws/dictation` — WebSocket upgrade; browser WS API cannot set //! headers, so the handler enforces the bearer //! (header or `?token=`) + origin itself before the //! upgrade (C4 / issue #1924) //! - `OPTIONS *` — CORS preflight (handled by outer CORS middleware) //! //! Endpoints that accept the bearer either via header **or** `?token=…` query //! param (see [`QUERY_TOKEN_PATHS`]): //! - `GET /events/webhooks` — webhook SSE; browser `EventSource` cannot set //! headers, so the FE forwards the bearer as a query param. Validated //! against the same in-process RPC token — no separate secret. //! //! Executable surfaces: //! - `POST /rpc` requires the per-launch core bearer token. //! - `GET /v1/models` and `POST /v1/chat/completions` accept either that //! internal bearer or a stable user-managed external API key stored under //! `openhuman::inference::http::EXTERNAL_OPENAI_COMPAT_PROVIDER`. use std::io::Write as _; use std::path::Path; use std::sync::OnceLock; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt as _; use axum::http::{header, Method, StatusCode}; use axum::middleware::Next; use axum::response::{IntoResponse, Response}; use axum::Json; use serde_json::json; use crate::openhuman::config::Config; use crate::openhuman::credentials::AuthService; use crate::openhuman::inference::http::EXTERNAL_OPENAI_COMPAT_PROVIDER; static RPC_TOKEN: OnceLock = OnceLock::new(); /// Paths that bypass bearer-token authentication. /// /// `/rpc` and `/v1/*` carry executable surfaces and must be protected. The /// other routes are read-only, or are streaming / WebSocket upgrades whose /// clients (browser `EventSource`, browser `WebSocket`) cannot set /// `Authorization` headers via standard APIs. `/events` is not unauthenticated /// — it is exempt from the *middleware* header check but enforces its own /// bind-token credential inside the handler. `/ws/dictation` is NOT public: it /// is bearer-gated by this middleware via [`QUERY_TOKEN_PATHS`] (header or /// `?token=`) so an unauthenticated upgrade is rejected with 401 before the /// WebSocket handshake; the handler adds an origin check on top (finding C4). const PUBLIC_PATHS: &[&str] = &[ "/", "/health", "/auth", "/auth/telegram", // External browser OAuth redirect for HTTP-remote MCP servers — the // authorization server posts back here with `?code=…&state=…` and no // bearer; the one-time `state` (minted in `oauth_begin`) is the guard. "/oauth/mcp/callback", "/schema", "/events", // AgentBox marketplace surface — see `openhuman::agentbox::http`. // Mounted only when `OPENHUMAN_AGENTBOX_MODE=1`; the public-path entry is // unconditional so the matcher remains a pure function of the path string. "/run", ]; /// Public path prefixes — match when the request path begins with any entry. /// /// Use this only when the suffix is dynamic (path params). For exact paths, /// add to [`PUBLIC_PATHS`] instead. const PUBLIC_PATH_PREFIXES: &[&str] = &[ // AgentBox `GET /jobs/{job_id}` — `{job_id}` is a UUID per submission. "/jobs/", ]; /// Returns `true` when `path` bypasses bearer-token authentication. /// /// A path is public when it appears in [`PUBLIC_PATHS`] (exact match) or /// begins with any entry in [`PUBLIC_PATH_PREFIXES`] (prefix match). fn is_public_path(path: &str) -> bool { PUBLIC_PATHS.contains(&path) || PUBLIC_PATH_PREFIXES .iter() .any(|prefix| path.starts_with(prefix)) } /// Paths that may authenticate via `?token=…` in the URL when no /// `Authorization` header is present. /// /// Browser `EventSource` cannot attach custom headers, so an SSE route that /// returns sensitive data (webhook deliveries, registration changes) is /// otherwise indistinguishable from a public endpoint — any local process on /// `127.0.0.1` can subscribe. Allowing the bearer in the query string lets /// the FE attach it explicitly while keeping a single token of truth /// (validated by [`bearer_matches`] against the same in-process RPC token). /// /// Add new entries here only for SSE / WebSocket routes whose clients cannot /// send headers and that carry per-user data. The follow-up approvals stream /// (#1339) is the next planned addition. const QUERY_TOKEN_PATHS: &[&str] = &["/events/webhooks", "/ws/dictation"]; /// Operator-supplied environment variable that carries the RPC bearer in /// non-desktop deployments. /// /// **The Tauri desktop shell does NOT set this variable.** Since PR #1061 /// the core runs in-process inside the Tauri host, and the shell hands the /// per-launch bearer to the embedded server via an internal in-memory handle /// (see [`init_rpc_token_with_value`]). The desktop boot flow never crosses /// a process-global env surface. /// /// `OPENHUMAN_CORE_TOKEN` remains the canonical configuration surface for /// **standalone CLI / Docker / cloud** deployments only — where the bearer /// must come from `fly secrets set …`, `docker run -e …`, a systemd unit /// file, or a developer running `openhuman-core serve` from a shell with the /// env var pre-set. In those shapes there is no live host process to hand /// the token over in-memory, so env-as-config is the appropriate transport. /// /// When this variable is present [`init_rpc_token`] uses its value (no file /// I/O). When absent and no in-memory token was seeded, `init_rpc_token` /// generates a fresh 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 from env-or-file (non-desktop path). /// /// **Not the desktop path.** The Tauri shell passes the per-launch bearer /// to the embedded server via the internal in-memory handle (see /// [`init_rpc_token_with_value`]); it does **not** set /// `OPENHUMAN_CORE_TOKEN`. This function is the bootstrap path for /// standalone CLI / Docker / cloud deployments. /// /// **Env-as-config (preferred for non-desktop)**: when /// `OPENHUMAN_CORE_TOKEN` is set in the process environment (typically by /// the container runtime, secrets manager, or systemd unit file), the core /// uses its value as the RPC token. No file is written; the token is /// available the instant the process starts. /// /// **Standalone CLI fallback**: when no env var is supplied, the core /// 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 standalone 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(()); } // Env-as-config path: bearer supplied by the operator via // OPENHUMAN_CORE_TOKEN. Used by Docker / cloud / systemd / a developer // running `openhuman-core serve` from a pre-configured shell. Desktop // (Tauri) does NOT set this variable — it uses `init_rpc_token_with_value` // for an in-memory handoff instead. 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 (operator-supplied)"); 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(()) } /// Seed the per-process RPC token directly from a caller-supplied value. /// /// **In-memory handoff path** — used by the Tauri shell to inject the bearer /// the host generated in `CoreProcessHandle::new()` into the in-process core /// without round-tripping through `OPENHUMAN_CORE_TOKEN` in the process /// environment. The token never lands on a process-global env surface, which /// keeps it off `/proc//environ` (Linux) and out of `sysctl /// KERN_PROCARGS2` / `ps eww -p ` (macOS) where any same-UID process /// could otherwise read it without entitlement. /// /// Idempotent: a second call is a no-op (matches [`init_rpc_token`] — flipping /// the in-memory bearer mid-life would 401 every in-flight client). /// /// # Errors /// /// Returns an error only if `token` is empty after trimming. A non-empty /// token is accepted as-is — callers are expected to have generated a /// CSPRNG hex string (see `CoreProcessHandle::generate_rpc_token`). pub fn init_rpc_token_with_value(token: &str) -> anyhow::Result<()> { let trimmed = token.trim(); if trimmed.is_empty() { anyhow::bail!("init_rpc_token_with_value: supplied token is empty"); } if RPC_TOKEN.get().is_some() { log::debug!("[auth] init_rpc_token_with_value: already initialized, skipping"); return Ok(()); } let _ = RPC_TOKEN.set(trimmed.to_string()); log::info!("[auth] core RPC token loaded via in-memory handoff (no env crossing)"); Ok(()) } /// Returns the active RPC token, if initialized. pub fn get_rpc_token() -> Option<&'static str> { RPC_TOKEN.get().map(String::as_str) } /// Validate a supplied bearer token against the active per-process RPC token. /// /// Returns `true` only when the token subsystem is initialised and the /// supplied token is non-empty and matches the in-memory expected value. /// /// This is the single entry point that non-HTTP transports (Socket.IO event /// handlers, SSE bind-token issuance, future WebSocket surfaces) should call /// before letting attacker-controlled input reach executable code. Keeping /// the comparison in one helper means every transport gets the same /// constant-time equality semantics. pub fn verify_bearer_token(supplied: &str) -> bool { let Some(expected) = get_rpc_token() else { return false; }; bearer_matches(supplied, expected) } /// Axum middleware: enforce `Authorization: Bearer ` on all protected /// endpoints. /// /// Public paths (see [`PUBLIC_PATHS`]) and CORS preflight `OPTIONS` requests /// bypass this check. `/rpc` requires the exact per-launch bearer token that /// was written to `core.token` at startup; `/v1/*` additionally accepts a /// stable user-managed external API key. 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 || is_public_path(&path) { 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 header_token = req .headers() .get(header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")) .unwrap_or(""); if bearer_matches(header_token, expected) { log::trace!("[auth] authorized request to {path} (header)"); return next.run(req).await; } if is_external_inference_path(&path) && verify_external_inference_bearer(header_token).await { log::trace!("[auth] authorized request to {path} (external inference bearer)"); return next.run(req).await; } // Header path failed — fall back to `?token=…` for SSE/WS routes whose // browser clients cannot set headers. The query token is validated // against the same in-process RPC bearer (single source of truth), so // this is not a separate credential — only a transport workaround. if QUERY_TOKEN_PATHS.contains(&path.as_str()) { if let Some(query_token) = extract_query_token(req.uri().query()) { if bearer_matches(&query_token, expected) { log::trace!("[auth] authorized request to {path} (query token)"); return next.run(req).await; } } } 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() } /// Single source of truth for token comparison. /// /// Use constant-time equality so callers that validate attacker-controlled /// bearer strings do not leak partial-match timing through HTTP, SSE, Socket.IO, /// or future transports that share this helper. fn bearer_matches(supplied: &str, expected: &str) -> bool { !supplied.is_empty() && constant_time_eq(supplied, expected) } fn constant_time_eq(a: &str, b: &str) -> bool { let a = a.as_bytes(); let b = b.as_bytes(); let len_diff = a.len() ^ b.len(); let max_len = a.len().max(b.len()); let mut byte_diff = 0u8; for i in 0..max_len { let left = *a.get(i).unwrap_or(&0); let right = *b.get(i).unwrap_or(&0); byte_diff |= left ^ right; } (len_diff == 0) & (byte_diff == 0) } fn is_external_inference_path(path: &str) -> bool { path == "/v1" || path.starts_with("/v1/") } fn verify_external_inference_bearer_for_config(config: &Config, supplied: &str) -> bool { if supplied.trim().is_empty() { return false; } let auth = AuthService::from_config(config); match auth.get_provider_bearer_token(EXTERNAL_OPENAI_COMPAT_PROVIDER, None) { Ok(Some(expected)) => bearer_matches(supplied, expected.trim()), Ok(None) => false, Err(err) => { log::warn!("[auth] failed to read external inference bearer: {err}"); false } } } async fn verify_external_inference_bearer(supplied: &str) -> bool { if supplied.trim().is_empty() { return false; } let config = match Config::load_or_init().await { Ok(config) => config, Err(err) => { log::warn!("[auth] failed to load config for external inference bearer: {err}"); return false; } }; verify_external_inference_bearer_for_config(&config, supplied) } /// Pull the first `token` query parameter out of a URL query string. /// /// Returns `None` when the query is absent, the key is missing, or the /// value is empty after trimming. URL decoding is delegated to /// [`url::form_urlencoded`] so percent-encoded tokens decode the same way /// they were encoded by the FE via `encodeURIComponent`. fn extract_query_token(query: Option<&str>) -> Option { let query = query?; for (key, value) in url::form_urlencoded::parse(query.as_bytes()) { if key == "token" { let value = value.trim().to_string(); if !value.is_empty() { return Some(value); } } } None } /// 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::RngExt as _; log::trace!("[auth] generate_token: start (32 bytes)"); let mut bytes = [0u8; 32]; rand::rng().fill(&mut bytes); let token = hex::encode(bytes); log::trace!("[auth] generate_token: complete (64 hex chars)"); token } /// 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(); } #[test] fn bearer_matches_rejects_empty_supplied() { let expected = "cafebabe"; assert!(!bearer_matches("", expected)); } #[test] fn bearer_matches_rejects_mismatch() { assert!(!bearer_matches("deadbeef", "cafebabe")); } #[test] fn bearer_matches_rejects_prefix_match() { assert!(!bearer_matches("cafeba", "cafebabe")); } #[test] fn bearer_matches_accepts_exact() { assert!(bearer_matches("cafebabe", "cafebabe")); } #[test] fn verify_bearer_token_returns_false_when_token_uninitialized() { // RPC_TOKEN is a process-global OnceLock; on a fresh test binary it // may already be set by another test that ran first, so we cannot // assert the uninitialized branch here without process isolation. // We can however confirm that an empty supplied value is always // rejected, which exercises the second-leg invariant. assert!(!verify_bearer_token("")); } #[test] fn init_rpc_token_with_value_rejects_empty() { // Trimmed-empty values must error rather than seed an empty bearer. assert!(init_rpc_token_with_value("").is_err()); assert!(init_rpc_token_with_value(" ").is_err()); } /// `init_rpc_token_with_value` populates the same `RPC_TOKEN` OnceLock /// that `get_rpc_token` reads — i.e. the in-memory handoff path produces /// the bearer everyone else (HTTP middleware, Socket.IO verifier, /// approval-gate session_id) reads from. We can't deterministically /// assert the *value* set here (the OnceLock may already be seeded by a /// sibling test that ran first in the same binary), but we can assert /// the OnceLock is initialised after this call returns Ok, and that the /// helper is idempotent. #[test] fn init_rpc_token_with_value_seeds_and_is_idempotent() { // First call: either we seed, or a sibling test already did. Either // way the helper must return Ok and leave `get_rpc_token` populated. let token = "cafebabe1234567890abcdef0123456789abcdef0123456789abcdef01234567"; init_rpc_token_with_value(token).expect("seed succeeds"); assert!( get_rpc_token().is_some(), "after init_rpc_token_with_value, get_rpc_token must return Some" ); // Second call is a no-op (matching init_rpc_token semantics) — must // not error, must not flip the in-memory value. let before = get_rpc_token().map(str::to_string); init_rpc_token_with_value("a-different-value-that-must-be-ignored") .expect("idempotent re-init succeeds"); let after = get_rpc_token().map(str::to_string); assert_eq!( before, after, "second init_rpc_token_with_value must not flip the in-memory bearer" ); } #[test] fn extract_query_token_returns_none_on_missing_query() { assert_eq!(extract_query_token(None), None); } #[test] fn extract_query_token_returns_none_when_key_absent() { assert_eq!(extract_query_token(Some("other=1&foo=bar")), None); } #[test] fn extract_query_token_returns_none_on_empty_value() { assert_eq!(extract_query_token(Some("token=")), None); assert_eq!(extract_query_token(Some("token=%20%20")), None); } #[test] fn extract_query_token_returns_first_value_on_duplicate_keys() { // Last-wins vs first-wins is a question the FE never hits; pin // first-wins so any future ambiguity is documented. assert_eq!( extract_query_token(Some("token=alpha&token=beta")), Some("alpha".to_string()) ); } #[test] fn extract_query_token_url_decodes_value() { // `encodeURIComponent` on the FE may percent-encode a hex token // accidentally (it shouldn't, but defensive); confirm round-trip. assert_eq!( extract_query_token(Some("token=cafe%2Dbabe")), Some("cafe-babe".to_string()) ); } #[test] fn public_paths_include_desktop_auth_callback() { assert!(PUBLIC_PATHS.contains(&"/auth")); } #[test] fn agentbox_run_and_jobs_paths_are_public() { // AgentBox marketplace surface bypasses bearer auth (gated externally // by `OPENHUMAN_AGENTBOX_MODE` at router-build time). assert!(is_public_path("/run")); assert!(is_public_path("/jobs/abc-123")); assert!(is_public_path("/jobs/00000000-0000-0000-0000-000000000000")); // Sanity: still protect the executable surface. assert!(!is_public_path("/rpc")); assert!(!is_public_path("/v1/chat/completions")); } #[cfg(unix)] #[test] fn token_file_has_owner_only_permissions() { use std::os::unix::fs::PermissionsExt as _; 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(); } #[test] fn is_external_inference_path_matches_only_v1_routes() { assert!(is_external_inference_path("/v1")); assert!(is_external_inference_path("/v1/models")); assert!(is_external_inference_path("/v1/chat/completions")); assert!(!is_external_inference_path("/rpc")); assert!(!is_external_inference_path("/v10/models")); } #[test] fn verify_external_inference_bearer_for_config_accepts_stored_key() { let tmp = tempfile::tempdir().unwrap(); let mut config = Config::default(); config.config_path = tmp.path().join("config.toml"); let auth = AuthService::from_config(&config); auth.store_provider_token( EXTERNAL_OPENAI_COMPAT_PROVIDER, "default", "external-test-key", std::collections::HashMap::new(), true, ) .unwrap(); assert!(verify_external_inference_bearer_for_config( &config, "external-test-key" )); assert!(!verify_external_inference_bearer_for_config( &config, "wrong-key" )); } }