diff --git a/Cargo.toml b/Cargo.toml index 7a9c9f5..42ca6b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,9 @@ clap = { version = "4", features = ["derive"] } # Lazy statics once_cell = "1" +# Random key generation +rand = "0.8" + # Image encoding (PNG screenshots) image = "0.25" diff --git a/README.md b/README.md index b51ad1b..95c869c 100644 --- a/README.md +++ b/README.md @@ -104,19 +104,19 @@ OculOS reads the OS accessibility tree and assigns each UI element a session-sco ```bash # 1. List open windows -curl http://localhost:7878/windows +curl -H "Authorization: Bearer $OCULOS_API_KEY" http://localhost:7878/windows # 2. Get the UI tree for a window -curl http://localhost:7878/windows/{pid}/tree +curl -H "Authorization: Bearer $OCULOS_API_KEY" http://localhost:7878/windows/{pid}/tree # 3. Find a specific element -curl "http://localhost:7878/windows/{pid}/find?q=Submit&type=Button" +curl -H "Authorization: Bearer $OCULOS_API_KEY" "http://localhost:7878/windows/{pid}/find?q=Submit&type=Button" # 4. Click it -curl -X POST http://localhost:7878/interact/{id}/click +curl -H "Authorization: Bearer $OCULOS_API_KEY" -X POST http://localhost:7878/interact/{id}/click # 5. Type into a text field -curl -X POST http://localhost:7878/interact/{id}/set-text \ +curl -H "Authorization: Bearer $OCULOS_API_KEY" -X POST http://localhost:7878/interact/{id}/set-text \ -H "Content-Type: application/json" \ -d '{"text":"hello world"}' ``` @@ -138,6 +138,8 @@ Every element includes an `actions` array — the API tells you exactly what you ## API +> All endpoints require authentication. Include `Authorization: Bearer ` header with every request. + ### Discovery | Endpoint | Description | @@ -293,11 +295,63 @@ oculos [OPTIONS] --static-dir Static files directory [default: static] --log Log level: trace/debug/info/warn/error [default: info] --mcp Run as MCP server over stdin/stdout + --api-key API key for HTTP auth [env: OCULOS_API_KEY] -h, --help Print help ``` --- +## Authentication + +All HTTP API endpoints, WebSocket connections, and the Dashboard require a Bearer token. + +### How it works + +- On startup, if no key is provided, OculOS generates a random one and prints it: + ``` + [OculOS] No API key provided. Generated key: oculos_a1b2c3... + [OculOS] Pass it with --api-key or set OCULOS_API_KEY env variable. + ``` +- You can provide your own key via CLI or environment variable: + ```bash + # CLI flag + oculos --api-key "my-secret-key" + + # Environment variable + export OCULOS_API_KEY="my-secret-key" + oculos + ``` + +### Using the API with auth + +Pass the key in the `Authorization` header: + +```bash +# List windows +curl -H "Authorization: Bearer oculos_a1b2c3..." http://localhost:7878/windows + +# Get UI tree +curl -H "Authorization: Bearer oculos_a1b2c3..." http://localhost:7878/windows/1234/tree + +# Click an element +curl -X POST -H "Authorization: Bearer oculos_a1b2c3..." \ + http://localhost:7878/interact//click +``` + +### Dashboard + +The web dashboard prompts for the API key on first visit. The key is saved in `localStorage` for the session. If the key becomes invalid (401 response), the login screen reappears. + +### WebSocket + +Browser WebSocket API cannot send custom headers. The dashboard passes the token via query parameter: `ws://host/ws?token=`. The `Authorization: Bearer` header also works for non-browser clients. + +### MCP mode + +MCP runs over stdin/stdout and does not use HTTP, so no API key is required in `--mcp` mode. + +--- + ## How OculOS Differs | | OculOS | Vision agents | Screen coordinate tools | Browser-only tools | diff --git a/src/api/mod.rs b/src/api/mod.rs index 9c0c37a..4dc79e5 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -2,9 +2,11 @@ pub mod interact; pub mod windows; pub mod ws; +use crate::auth::{self, ApiKey}; use crate::platform::UiBackend; use crate::types::ApiResponse; use axum::{ + middleware, routing::{get, post}, Json, Router, }; @@ -21,10 +23,12 @@ pub struct AppState { pub ws_tx: ws::WsBroadcast, } -pub fn router(state: AppState) -> Router { +pub fn router(state: AppState, api_key: String) -> Router { // Touch the lazy so uptime starts counting from server boot. Lazy::force(&START_TIME); + let key_ext = ApiKey(api_key); + Router::new() // ── Discovery ────────────────────────────────────────────────────── .route("/windows", get(windows::list_windows)) @@ -62,6 +66,9 @@ pub fn router(state: AppState) -> Router { .route("/health", get(health)) // ── WebSocket ───────────────────────────────────────────────────── .route("/ws", get(ws::ws_handler)) + // ── Auth middleware (applies to all routes above) ───────────────── + .layer(middleware::from_fn(auth::auth_middleware)) + .layer(axum::Extension(key_ext)) .with_state(state) } diff --git a/src/api/ws.rs b/src/api/ws.rs index b5b3d24..6a35c63 100644 --- a/src/api/ws.rs +++ b/src/api/ws.rs @@ -37,7 +37,9 @@ pub fn create_broadcast() -> WsBroadcast { Arc::new(tx) } -/// GET /ws — upgrade to WebSocket +/// GET /ws — upgrade to WebSocket. +/// Auth is handled by the auth middleware layer (supports both +/// `Authorization: Bearer` header and `?token=` query parameter). pub async fn ws_handler(ws: WebSocketUpgrade, State(s): State) -> impl IntoResponse { ws.on_upgrade(move |socket| handle_socket(socket, s)) } diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..49b4b48 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,129 @@ +//! Bearer token authentication middleware for OculOS HTTP API. + +use axum::{ + extract::Request, + http::StatusCode, + middleware::Next, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; +use tracing::info; + +/// Resolve the API key: use the provided value, or generate a random one. +pub fn resolve_api_key(provided: Option) -> String { + match provided { + Some(key) if !key.is_empty() => { + info!("[OculOS] Using provided API key."); + key + } + _ => { + let key = generate_key(); + info!("[OculOS] No API key provided. Generated key: {}", key); + info!("[OculOS] Pass it with --api-key or set OCULOS_API_KEY env variable."); + key + } + } +} + +/// Generate a cryptographically random API key: `oculos_<32 bytes hex>`. +fn generate_key() -> String { + use rand::Rng; + let mut bytes = [0u8; 32]; + rand::thread_rng().fill(&mut bytes); + format!("oculos_{}", hex_encode(&bytes)) +} + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// Axum middleware that validates the API key on every request. +/// +/// Checks in order: +/// 1. `Authorization: Bearer ` header +/// 2. `?token=` query parameter (needed for browser WebSocket connections) +pub async fn auth_middleware(request: Request, next: Next) -> Response { + let expected = request + .extensions() + .get::() + .map(|k| k.0.clone()); + + let expected = match expected { + Some(k) => k, + None => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "Internal Server Error", "message": "Auth not configured"}))).into_response(), + }; + + // 1. Check Authorization header + let header_key = request + .headers() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|h| h.strip_prefix("Bearer ")); + + if let Some(key) = header_key { + if constant_time_eq(key, &expected) { + return next.run(request).await; + } + } + + // 2. Check ?token= query parameter (for WebSocket from browsers) + if let Some(query) = request.uri().query() { + for pair in query.split('&') { + if let Some(value) = pair.strip_prefix("token=") { + let decoded = urlencoding_decode(value); + if constant_time_eq(&decoded, &expected) { + return next.run(request).await; + } + } + } + } + + ( + StatusCode::UNAUTHORIZED, + Json(json!({ + "error": "Unauthorized", + "message": "Invalid or missing API key" + })), + ) + .into_response() +} + +/// Minimal percent-decoding for the token query parameter. +fn urlencoding_decode(s: &str) -> String { + let mut result = String::with_capacity(s.len()); + let mut chars = s.bytes(); + while let Some(b) = chars.next() { + if b == b'%' { + let hi = chars.next().unwrap_or(0); + let lo = chars.next().unwrap_or(0); + if let (Some(h), Some(l)) = (hex_val(hi), hex_val(lo)) { + result.push((h << 4 | l) as char); + } + } else if b == b'+' { + result.push(' '); + } else { + result.push(b as char); + } + } + result +} + +fn hex_val(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(b - b'0'), + b'a'..=b'f' => Some(b - b'a' + 10), + b'A'..=b'F' => Some(b - b'A' + 10), + _ => None, + } +} + +/// Constant-time string comparison to prevent timing attacks. +fn constant_time_eq(a: &str, b: &str) -> bool { + if a.len() != b.len() { return false; } + a.bytes().zip(b.bytes()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} + +/// Newtype wrapper so we can insert the key into request extensions. +#[derive(Clone)] +pub struct ApiKey(pub String); diff --git a/src/main.rs b/src/main.rs index f1ec9df..fcd6b02 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod api; +mod auth; mod mcp; mod platform; mod types; @@ -39,6 +40,11 @@ struct Args { /// Add this binary to your MCP host config (Claude, Cursor, Windsurf…). #[arg(long)] mcp: bool, + + /// API key for authenticating HTTP requests. If not provided, a random key + /// is generated at startup. Can also be set via OCULOS_API_KEY env variable. + #[arg(long, env = "OCULOS_API_KEY")] + api_key: Option, } #[tokio::main] @@ -67,6 +73,9 @@ async fn main() -> Result<()> { return Ok(()); } + // ── API key ─────────────────────────────────────────────────────────────── + let api_key = auth::resolve_api_key(args.api_key); + let ws_tx = api::ws::create_broadcast(); let state = AppState { backend, ws_tx }; @@ -77,12 +86,12 @@ async fn main() -> Result<()> { .allow_headers(Any); // ── Router ──────────────────────────────────────────────────────────────── - let api_routes = api::router(state); + let api_routes = api::router(state, api_key.clone()); let app = Router::new() // API routes under /api namespace (also available at root for simplicity) .merge(api_routes) - // Dashboard — served at / + // Dashboard — served at / (no auth required for static files) .nest_service("/", ServeDir::new(&args.static_dir)) .layer(cors) .layer(TraceLayer::new_for_http()); diff --git a/static/index.html b/static/index.html index 9dd5808..6a3f08f 100644 --- a/static/index.html +++ b/static/index.html @@ -146,6 +146,17 @@ .loading{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--blue);border-radius:50%;animation:spin .6s linear infinite} @keyframes spin{to{transform:rotate(360deg)}} +/* ── login overlay ── */ +.login-overlay{position:fixed;inset:0;background:var(--bg);z-index:1000;display:none;align-items:center;justify-content:center} +.login-box{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:32px;width:360px;text-align:center} +.login-box h2{font:600 18px var(--sans);color:var(--text);margin-bottom:4px} +.login-box p{font:13px var(--sans);color:var(--text-3);margin-bottom:20px} +.login-box input{width:100%;height:36px;background:var(--raised);border:1px solid var(--border);border-radius:4px;padding:0 12px;font:13px var(--mono);color:var(--text);outline:none;margin-bottom:12px} +.login-box input:focus{border-color:var(--blue)} +.login-box button{width:100%;height:36px;background:var(--blue);border:none;border-radius:4px;font:500 13px var(--sans);color:#fff;cursor:pointer} +.login-box button:hover{background:#5a96ff} +.login-err{color:var(--red);font-size:12px;margin-bottom:8px;display:none} + /* ── toast ── */ .toast-wrap{position:fixed;bottom:16px;right:16px;display:flex;flex-direction:column;gap:6px;z-index:999;pointer-events:none} .toast{padding:8px 14px;border-radius:var(--radius);font:12px var(--sans);color:var(--text);background:var(--raised);border:1px solid var(--border);box-shadow:0 4px 16px rgba(0,0,0,.4);opacity:0;transform:translateY(8px);animation:toast-in .2s forwards} @@ -188,6 +199,17 @@ + +
OculOS
@@ -288,6 +310,33 @@