Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
64 changes: 59 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"}'
```
Expand All @@ -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 <KEY>` header with every request.

### Discovery

| Endpoint | Description |
Expand Down Expand Up @@ -293,11 +295,63 @@ oculos [OPTIONS]
--static-dir <DIR> Static files directory [default: static]
--log <LEVEL> Log level: trace/debug/info/warn/error [default: info]
--mcp Run as MCP server over stdin/stdout
--api-key <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/<element-id>/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=<KEY>`. 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 |
Expand Down
9 changes: 8 additions & 1 deletion src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand All @@ -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))
Expand Down Expand Up @@ -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)
}

Expand Down
4 changes: 3 additions & 1 deletion src/api/ws.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppState>) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, s))
}
Expand Down
129 changes: 129 additions & 0 deletions src/auth.rs
Original file line number Diff line number Diff line change
@@ -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>) -> 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 <KEY>` header
/// 2. `?token=<KEY>` query parameter (needed for browser WebSocket connections)
pub async fn auth_middleware(request: Request, next: Next) -> Response {
let expected = request
.extensions()
.get::<ApiKey>()
.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<u8> {
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);
13 changes: 11 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod api;
mod auth;
mod mcp;
mod platform;
mod types;
Expand Down Expand Up @@ -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<String>,
}

#[tokio::main]
Expand Down Expand Up @@ -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 };

Expand All @@ -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());
Expand Down
Loading