diff --git a/Cargo.lock b/Cargo.lock index 03eb317111b4..01a3601b7e14 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7169,6 +7169,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ + "base64 0.21.7", "bitflags 2.9.0", "bytes", "futures-util", diff --git a/crates/goose-cli/Cargo.toml b/crates/goose-cli/Cargo.toml index fb16ced65e80..b4e3d3da4c1a 100644 --- a/crates/goose-cli/Cargo.toml +++ b/crates/goose-cli/Cargo.toml @@ -52,7 +52,7 @@ nix = { version = "0.30.1", features = ["process", "signal"] } tar = "0.4" # Web server dependencies axum = { version = "0.8.1", features = ["ws", "macros"] } -tower-http = { version = "0.5", features = ["cors", "fs"] } +tower-http = { version = "0.5", features = ["cors", "fs", "auth"] } http = "1.0" webbrowser = "1.0" indicatif = "0.17.11" diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index a760a8b98fd1..4600e5a11bb0 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -703,6 +703,10 @@ enum Command { /// Open browser automatically #[arg(long, help = "Open browser automatically when server starts")] open: bool, + + /// Authentication token for both Basic Auth (password) and Bearer token + #[arg(long, help = "Authentication token to secure the web interface")] + auth_token: Option, }, } @@ -1211,8 +1215,13 @@ pub async fn cli() -> Result<()> { } return Ok(()); } - Some(Command::Web { port, host, open }) => { - crate::commands::web::handle_web(port, host, open).await?; + Some(Command::Web { + port, + host, + open, + auth_token, + }) => { + crate::commands::web::handle_web(port, host, open, auth_token).await?; return Ok(()); } None => { diff --git a/crates/goose-cli/src/commands/web.rs b/crates/goose-cli/src/commands/web.rs index 4543065d38b0..f9bb53c0f27a 100644 --- a/crates/goose-cli/src/commands/web.rs +++ b/crates/goose-cli/src/commands/web.rs @@ -1,26 +1,27 @@ use anyhow::Result; +use axum::response::Redirect; use axum::{ extract::{ ws::{Message, WebSocket, WebSocketUpgrade}, - State, + Request, State, }, + http::StatusCode, + middleware::{self, Next}, response::{Html, IntoResponse, Response}, routing::get, Json, Router, }; -use goose::session::SessionManager; -use webbrowser; - +use base64::Engine; use futures::{sink::SinkExt, stream::StreamExt}; use goose::agents::{Agent, AgentEvent}; use goose::conversation::message::Message as GooseMessage; - -use axum::response::Redirect; +use goose::session::SessionManager; use serde::{Deserialize, Serialize}; use std::{net::SocketAddr, sync::Arc}; use tokio::sync::{Mutex, RwLock}; use tower_http::cors::{Any, CorsLayer}; use tracing::error; +use webbrowser; type CancellationStore = Arc>>; @@ -28,6 +29,7 @@ type CancellationStore = Arc, cancellations: CancellationStore, + auth_token: Option, } #[derive(Serialize, Deserialize)] @@ -78,7 +80,59 @@ enum WebSocketMessage { Complete { message: String }, } -pub async fn handle_web(port: u16, host: String, open: bool) -> Result<()> { +async fn auth_middleware( + State(state): State, + req: Request, + next: Next, +) -> Result { + // Skip auth for health check + if req.uri().path() == "/api/health" { + return Ok(next.run(req).await); + } + + // If no auth token is configured, skip authentication entirely + let Some(ref expected_token) = state.auth_token else { + return Ok(next.run(req).await); + }; + + // Check for Bearer token first + if let Some(auth_header) = req.headers().get("authorization") { + if let Ok(auth_str) = auth_header.to_str() { + if let Some(token) = auth_str.strip_prefix("Bearer ") { + if token == expected_token { + return Ok(next.run(req).await); + } + } + + // Check for Basic auth (password-only, ignore username) + if let Some(basic_token) = auth_str.strip_prefix("Basic ") { + if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(basic_token) { + if let Ok(credentials) = String::from_utf8(decoded) { + if credentials.ends_with(expected_token) { + return Ok(next.run(req).await); + } + } + } + } + } + } + + // Authentication failed - return 401 with WWW-Authenticate header + let mut response = Response::new("Authentication required".into()); + *response.status_mut() = StatusCode::UNAUTHORIZED; + response.headers_mut().insert( + "WWW-Authenticate", + "Basic realm=\"Goose Web Interface\"".parse().unwrap(), + ); + Ok(response) +} + +pub async fn handle_web( + port: u16, + host: String, + open: bool, + auth_token: Option, +) -> Result<()> { // Setup logging crate::logging::setup_logging(Some("goose-web"), None)?; @@ -125,6 +179,7 @@ pub async fn handle_web(port: u16, host: String, open: bool) -> Result<()> { let state = AppState { agent: Arc::new(agent), cancellations: Arc::new(RwLock::new(std::collections::HashMap::new())), + auth_token, }; // Build router @@ -136,6 +191,10 @@ pub async fn handle_web(port: u16, host: String, open: bool) -> Result<()> { .route("/api/sessions", get(list_sessions)) .route("/api/sessions/{session_id}", get(get_session)) .route("/static/{*path}", get(serve_static)) + .layer(middleware::from_fn_with_state( + state.clone(), + auth_middleware, + )) .layer( CorsLayer::new() .allow_origin(Any)