Cli web auth token (#4456)
Signed-off-by: Evanfeenstra <evanfeenstra@gmail.com>
This commit is contained in:
Generated
+1
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<String>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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<RwLock<std::collections::HashMap<String, tokio::task::AbortHandle>>>;
|
||||
|
||||
@@ -28,6 +29,7 @@ type CancellationStore = Arc<RwLock<std::collections::HashMap<String, tokio::tas
|
||||
struct AppState {
|
||||
agent: Arc<Agent>,
|
||||
cancellations: CancellationStore,
|
||||
auth_token: Option<String>,
|
||||
}
|
||||
|
||||
#[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<AppState>,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
// 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<String>,
|
||||
) -> 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)
|
||||
|
||||
Reference in New Issue
Block a user