Cli web auth token (#4456)

Signed-off-by: Evanfeenstra <evanfeenstra@gmail.com>
This commit is contained in:
Evan Feenstra
2025-09-29 13:05:17 -07:00
committed by GitHub
parent 509fcac69d
commit 41d32c04b1
4 changed files with 79 additions and 10 deletions
Generated
+1
View File
@@ -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",
+1 -1
View File
@@ -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"
+11 -2
View File
@@ -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 => {
+66 -7
View File
@@ -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)