mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* refactor: centralize test credential constants into testing::credentials Scattered test credential strings (API keys, OAuth tokens, crypto keys, Telegram tokens, session tokens) across ~25 files made security auditing harder and created unnecessary duplication. Centralize all test-only fake credentials into a new `src/testing/credentials.rs` module with named constants and a shared `test_secrets_store()` helper. - Convert `src/testing.rs` to directory module (`src/testing/mod.rs`) - Add `src/testing/credentials.rs` with ~30 named constants - Replace hardcoded literals in 24 source files - Deduplicate `test_store()` helper (was copy-pasted in 3 files) - Leave leak_detector/shell/signature tests as-is (inline values aid readability for pattern detection tests) [skip-regression-check] Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor: replace real Telegram bot token with obviously fake test stub Co-Authored-By: Claude Sonnet 4.6 <[email protected]> * Update src/testing/credentials.rs Co-authored-by: Copilot <[email protected]> * Update src/testing/credentials.rs Co-authored-by: Copilot <[email protected]> * refactor: address PR review feedback on test credentials - Fix TEST_CRYPTO_KEY doc comment ("32-byte hex" → "32-character key string") - Rename confusing "real"/"fake" Anthropic constant names and values - Change TEST_STRIPE_KEY from "sk-live" to "sk_test_fake123" to avoid scanners - Use test_secrets_store() helper in orchestrator and http tool tests - Clarify config_round_trip.rs doc comment about integration test visibility Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]> Co-authored-by: Copilot <[email protected]>
310 lines
10 KiB
Rust
310 lines
10 KiB
Rust
//! 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<String> {
|
|
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<AuthState>,
|
|
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);
|
|
}
|
|
}
|