//! Bearer token authentication middleware for the web gateway. use axum::{ extract::{Request, State}, http::{HeaderMap, Method, StatusCode}, middleware::Next, response::{IntoResponse, Response}, }; use subtle::ConstantTimeEq; /// Shared auth state injected via axum middleware state. #[derive(Clone)] pub struct AuthState { pub token: String, } /// Whether query-string token auth is allowed for this request. /// /// Only GET requests to streaming endpoints may use `?token=xxx`. This /// minimizes token-in-URL exposure on state-changing routes, where the token /// would leak via server logs, Referer headers, and browser history. /// /// Allowed endpoints: /// - SSE: `/api/chat/events`, `/api/logs/events` (EventSource can't set headers) /// - WebSocket: `/api/chat/ws` (WS upgrade can't set custom headers) /// /// If you add a new SSE or WebSocket endpoint, add its path here. fn allows_query_token_auth(request: &Request) -> bool { if request.method() != Method::GET { return false; } matches!( request.uri().path(), "/api/chat/events" | "/api/logs/events" | "/api/chat/ws" ) } /// Extract the `token` query parameter value, URL-decoded. fn query_token(request: &Request) -> Option { let query = request.uri().query()?; url::form_urlencoded::parse(query.as_bytes()).find_map(|(k, v)| { if k == "token" { Some(v.into_owned()) } else { None } }) } /// Auth middleware that validates bearer token from header or query param. /// /// SSE connections can't set headers from `EventSource`, so we also accept /// `?token=xxx` as a query parameter, but only on SSE endpoints. pub async fn auth_middleware( State(auth): State, headers: HeaderMap, request: Request, next: Next, ) -> Response { // Try Authorization header first (constant-time comparison). // RFC 6750 Section 2.1: auth-scheme comparison is case-insensitive. if let Some(auth_header) = headers.get("authorization") && let Ok(value) = auth_header.to_str() && value.len() > 7 && value[..7].eq_ignore_ascii_case("Bearer ") && bool::from(value.as_bytes()[7..].ct_eq(auth.token.as_bytes())) { return next.run(request).await; } // Fall back to query parameter, but only for SSE endpoints (constant-time comparison). if allows_query_token_auth(&request) && let Some(token) = query_token(&request) && bool::from(token.as_bytes().ct_eq(auth.token.as_bytes())) { return next.run(request).await; } (StatusCode::UNAUTHORIZED, "Invalid or missing auth token").into_response() } #[cfg(test)] mod tests { use super::*; use crate::testing::credentials::{TEST_AUTH_SECRET_TOKEN, TEST_BEARER_TOKEN}; #[test] fn test_auth_state_clone() { let state = AuthState { token: TEST_BEARER_TOKEN.to_string(), }; let cloned = state.clone(); assert_eq!(cloned.token, TEST_BEARER_TOKEN); } use axum::Router; use axum::body::Body; use axum::middleware; use axum::routing::{get, post}; use tower::ServiceExt; async fn dummy_handler() -> &'static str { "ok" } /// Router with streaming endpoints (query auth allowed) and regular /// endpoints (query auth rejected). fn test_app(token: &str) -> Router { let state = AuthState { token: token.to_string(), }; Router::new() .route("/api/chat/events", get(dummy_handler)) .route("/api/logs/events", get(dummy_handler)) .route("/api/chat/ws", get(dummy_handler)) .route("/api/chat/history", get(dummy_handler)) .route("/api/chat/send", post(dummy_handler)) .layer(middleware::from_fn_with_state(state, auth_middleware)) } #[tokio::test] async fn test_valid_bearer_token_passes() { let app = test_app(TEST_AUTH_SECRET_TOKEN); let req = Request::builder() .uri("/api/chat/events") .header("Authorization", format!("Bearer {TEST_AUTH_SECRET_TOKEN}")) .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn test_invalid_bearer_token_rejected() { let app = test_app(TEST_AUTH_SECRET_TOKEN); let req = Request::builder() .uri("/api/chat/events") .header("Authorization", "Bearer wrong-token") .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn test_query_token_allowed_for_chat_events() { let app = test_app(TEST_AUTH_SECRET_TOKEN); let req = Request::builder() .uri(format!("/api/chat/events?token={TEST_AUTH_SECRET_TOKEN}")) .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn test_query_token_allowed_for_logs_events() { let app = test_app(TEST_AUTH_SECRET_TOKEN); let req = Request::builder() .uri(format!("/api/logs/events?token={TEST_AUTH_SECRET_TOKEN}")) .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn test_query_token_allowed_for_ws_upgrade() { let app = test_app(TEST_AUTH_SECRET_TOKEN); let req = Request::builder() .uri(format!("/api/chat/ws?token={TEST_AUTH_SECRET_TOKEN}")) .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn test_query_token_url_encoded() { // Token with characters that get percent-encoded in URLs. let raw_token = "tok+en/with spaces"; let app = test_app(raw_token); let req = Request::builder() .uri("/api/chat/events?token=tok%2Ben%2Fwith%20spaces") .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn test_query_token_url_encoded_mismatch() { let app = test_app("real-token"); // Encoded value decodes to "wrong-token", not "real-token". let req = Request::builder() .uri("/api/chat/events?token=wrong%2Dtoken") .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn test_query_token_rejected_for_non_sse_get() { let app = test_app(TEST_AUTH_SECRET_TOKEN); let req = Request::builder() .uri(format!("/api/chat/history?token={TEST_AUTH_SECRET_TOKEN}")) .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn test_query_token_rejected_for_post() { let app = test_app(TEST_AUTH_SECRET_TOKEN); let req = Request::builder() .method(Method::POST) .uri(format!("/api/chat/send?token={TEST_AUTH_SECRET_TOKEN}")) .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn test_query_token_invalid_rejected() { let app = test_app(TEST_AUTH_SECRET_TOKEN); let req = Request::builder() .uri("/api/chat/events?token=wrong-token") .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn test_no_auth_at_all_rejected() { let app = test_app(TEST_AUTH_SECRET_TOKEN); let req = Request::builder() .uri("/api/chat/events") .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn test_bearer_header_works_for_post() { let app = test_app(TEST_AUTH_SECRET_TOKEN); let req = Request::builder() .method(Method::POST) .uri("/api/chat/send") .header("Authorization", format!("Bearer {TEST_AUTH_SECRET_TOKEN}")) .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn test_bearer_prefix_case_insensitive() { let app = test_app(TEST_AUTH_SECRET_TOKEN); let req = Request::builder() .uri("/api/chat/events") .header("Authorization", format!("bearer {TEST_AUTH_SECRET_TOKEN}")) .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn test_bearer_prefix_mixed_case() { let app = test_app(TEST_AUTH_SECRET_TOKEN); let req = Request::builder() .uri("/api/chat/events") .header("Authorization", format!("BEARER {TEST_AUTH_SECRET_TOKEN}")) .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn test_empty_bearer_token_rejected() { let app = test_app(TEST_AUTH_SECRET_TOKEN); let req = Request::builder() .uri("/api/chat/events") .header("Authorization", "Bearer ") .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn test_token_with_whitespace_rejected() { let app = test_app(TEST_AUTH_SECRET_TOKEN); let req = Request::builder() .uri("/api/chat/events") .header("Authorization", format!("Bearer {TEST_AUTH_SECRET_TOKEN}")) .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } }