mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 05:12:33 +00:00
363 lines
13 KiB
Rust
363 lines
13 KiB
Rust
//! 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 <token>`. 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 /ws/dictation` — WebSocket upgrade; browser WS API cannot set headers
|
|
//! - `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.
|
|
//!
|
|
//! 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<String> = 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",
|
|
"/ws/dictation",
|
|
];
|
|
|
|
/// 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"];
|
|
|
|
/// 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 <token>` 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 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;
|
|
}
|
|
|
|
// 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 <token>'."
|
|
})),
|
|
)
|
|
.into_response()
|
|
}
|
|
|
|
/// Single source of truth for token comparison. Hex tokens of fixed length
|
|
/// make the comparison non-secret-shaped, but we still pin a deliberate
|
|
/// helper so adding constant-time semantics later is a one-line change.
|
|
fn bearer_matches(supplied: &str, expected: &str) -> bool {
|
|
!supplied.is_empty() && supplied == expected
|
|
}
|
|
|
|
/// 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<String> {
|
|
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_accepts_exact() {
|
|
assert!(bearer_matches("cafebabe", "cafebabe"));
|
|
}
|
|
|
|
#[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())
|
|
);
|
|
}
|
|
|
|
#[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();
|
|
}
|
|
}
|