Files
RustBox/src/auth/middleware.rs
T
Mateusz Gruszczyński 425ea7449b
RustBox CI / rustbox (push) Canceled after 0s
v0.1.0
2026-10-30 17:22:17 +01:00

596 lines
20 KiB
Rust

use std::sync::Arc;
use axum::extract::Request;
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use super::session::SessionStore;
pub const BROWSER_COOKIE: &str = "rustbox_browser";
pub const ACTIVE_ACCOUNT_HEADER: &str = "X-Active-Account";
const UNAUTHORIZED_BODY: &str =
r#"{"error":{"code":"UNAUTHORIZED","message":"Invalid or expired session","status":401}}"#;
fn unauthorized() -> Response {
(
StatusCode::UNAUTHORIZED,
[("content-type", "application/json")],
UNAUTHORIZED_BODY,
)
.into_response()
}
fn account_expired(account_id: &str) -> Response {
let body = serde_json::json!({
"error": {
"code": "ACCOUNT_SESSION_EXPIRED",
"message": "Account session has expired",
"status": 401,
"accountId": account_id,
}
})
.to_string();
(
StatusCode::UNAUTHORIZED,
[("content-type", "application/json")],
body,
)
.into_response()
}
fn extract_cookie_value(req: &Request, cookie_name: &str) -> Option<String> {
for value in req.headers().get_all("cookie") {
let Ok(header_str) = value.to_str() else {
continue;
};
for segment in header_str.split(';') {
let trimmed = segment.trim();
let prefix = format!("{}=", cookie_name);
if let Some(cookie_value) = trimmed.strip_prefix(&prefix) {
let cookie_value = cookie_value.trim();
if !cookie_value.is_empty() {
return Some(cookie_value.to_string());
}
}
}
}
None
}
fn extract_session_cookie(req: &Request, account_id: &str) -> Option<String> {
let cookie_name = format!("rustbox_session_{}", account_id);
extract_cookie_value(req, &cookie_name)
}
fn extract_active_account_header(req: &Request) -> Option<String> {
req.headers()
.get(ACTIVE_ACCOUNT_HEADER)
.and_then(|v| v.to_str().ok())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
fn extract_active_account_query(req: &Request) -> Option<String> {
let uri = req.uri();
let query = uri.query()?;
for pair in query.split('&') {
if let Some(value) = pair.strip_prefix("account_id=") {
let decoded = urlencoding_decode(value);
if !decoded.is_empty() {
return Some(decoded);
}
}
}
None
}
fn urlencoding_decode(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '%' {
let hex: String = chars.by_ref().take(2).collect();
if let Ok(byte) = u8::from_str_radix(&hex, 16) {
result.push(byte as char);
} else {
result.push('%');
result.push_str(&hex);
}
} else if c == '+' {
result.push(' ');
} else {
result.push(c);
}
}
result
}
pub async fn auth_guard(mut req: Request, next: Next) -> Response {
let store = match req.extensions().get::<Arc<SessionStore>>() {
Some(s) => Arc::clone(s),
None => return unauthorized(),
};
let browser_id = match extract_cookie_value(&req, BROWSER_COOKIE) {
Some(b) => b,
None => return unauthorized(),
};
let account_id =
match extract_active_account_header(&req).or_else(|| extract_active_account_query(&req)) {
Some(a) => a,
None => return unauthorized(),
};
let token = match extract_session_cookie(&req, &account_id) {
Some(t) => t,
None => return account_expired(&account_id),
};
let session = match store
.get_account_session_shared(&browser_id, &account_id, &token)
.await
{
Some(s) => s,
None => return account_expired(&account_id),
};
req.extensions_mut().insert(session);
next.run(req).await
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::middleware;
use axum::response::IntoResponse;
use axum::routing::get;
use axum::{Extension, Router};
use http_body_util::BodyExt;
use tower::ServiceExt;
use super::*;
use crate::auth::session::{AccountSession, SessionStore};
fn guarded_router(store: Arc<SessionStore>) -> Router {
let handler = |Extension(session): Extension<AccountSession>| async move {
serde_json::json!({ "email": session.email })
.to_string()
.into_response()
};
Router::new()
.route("/protected", get(handler))
.layer(middleware::from_fn(auth_guard))
.layer(Extension(store))
}
async fn send(router: Router, req: Request<Body>) -> (StatusCode, serde_json::Value) {
let resp = router.oneshot(req).await.expect("request should succeed");
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let json: serde_json::Value =
serde_json::from_slice(&bytes).expect("body should be valid JSON");
(status, json)
}
struct TestSession {
browser_id: String,
account_id: String,
token: String,
}
fn create_test_session(
store: &SessionStore,
email: &str,
password: &str,
user_hash: &str,
) -> TestSession {
let browser_id = store.create_browser();
let (token, account_id) = store.add_account_to_browser(
&browser_id,
email.to_string(),
password.to_string(),
user_hash.to_string(),
"imap.example.com".to_string(),
993,
true,
"smtp.example.com".to_string(),
587,
true,
);
TestSession {
browser_id,
account_id,
token,
}
}
fn build_auth_headers(session: &TestSession) -> String {
format!(
"rustbox_browser={}; rustbox_session_{}={}",
session.browser_id, session.account_id, session.token
)
}
#[tokio::test]
async fn account_expired_matches_frontend_contract() {
let response = account_expired("expired-account");
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
assert_eq!(response.headers()["content-type"], "application/json");
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let actual: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let expected: serde_json::Value = serde_json::from_str(include_str!(
"../../tests/fixtures/account-session-expired.json"
))
.unwrap();
assert_eq!(actual, expected);
}
#[tokio::test]
async fn no_cookies_returns_401() {
let store = Arc::new(SessionStore::new(Duration::from_secs(3600)));
let router = guarded_router(store);
let req = Request::builder()
.uri("/protected")
.body(Body::empty())
.unwrap();
let (status, json) = send(router, req).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(json["error"]["code"], "UNAUTHORIZED");
}
#[tokio::test]
async fn missing_browser_cookie_returns_401() {
let store = Arc::new(SessionStore::new(Duration::from_secs(3600)));
let session = create_test_session(&store, "alice@example.com", "hunter2", "abc123");
let router = guarded_router(Arc::clone(&store));
let req = Request::builder()
.uri("/protected")
.header(ACTIVE_ACCOUNT_HEADER, &session.account_id)
.header(
"cookie",
format!("rustbox_session_{}={}", session.account_id, session.token),
)
.body(Body::empty())
.unwrap();
let (status, json) = send(router, req).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(json["error"]["code"], "UNAUTHORIZED");
}
#[tokio::test]
async fn missing_active_account_header_returns_401() {
let store = Arc::new(SessionStore::new(Duration::from_secs(3600)));
let session = create_test_session(&store, "alice@example.com", "hunter2", "abc123");
let router = guarded_router(Arc::clone(&store));
let req = Request::builder()
.uri("/protected")
.header("cookie", format!("rustbox_browser={}", session.browser_id))
.body(Body::empty())
.unwrap();
let (status, json) = send(router, req).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(json["error"]["code"], "UNAUTHORIZED");
}
#[tokio::test]
async fn missing_session_cookie_returns_account_expired() {
let store = Arc::new(SessionStore::new(Duration::from_secs(3600)));
let session = create_test_session(&store, "alice@example.com", "hunter2", "abc123");
let router = guarded_router(Arc::clone(&store));
let req = Request::builder()
.uri("/protected")
.header(ACTIVE_ACCOUNT_HEADER, &session.account_id)
.header("cookie", format!("rustbox_browser={}", session.browser_id))
.body(Body::empty())
.unwrap();
let (status, json) = send(router, req).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(json["error"]["code"], "ACCOUNT_SESSION_EXPIRED");
assert_eq!(json["error"]["accountId"], session.account_id);
}
#[tokio::test]
async fn invalid_token_returns_account_expired() {
let store = Arc::new(SessionStore::new(Duration::from_secs(3600)));
let session = create_test_session(&store, "alice@example.com", "hunter2", "abc123");
let router = guarded_router(Arc::clone(&store));
let req = Request::builder()
.uri("/protected")
.header(ACTIVE_ACCOUNT_HEADER, &session.account_id)
.header(
"cookie",
format!(
"rustbox_browser={}; rustbox_session_{}=invalid-token",
session.browser_id, session.account_id
),
)
.body(Body::empty())
.unwrap();
let (status, json) = send(router, req).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(json["error"]["code"], "ACCOUNT_SESSION_EXPIRED");
assert_eq!(json["error"]["accountId"], session.account_id);
}
#[tokio::test]
async fn valid_session_returns_200_with_email() {
let store = Arc::new(SessionStore::new(Duration::from_secs(3600)));
let session = create_test_session(&store, "alice@example.com", "hunter2", "abc123");
let router = guarded_router(Arc::clone(&store));
let req = Request::builder()
.uri("/protected")
.header(ACTIVE_ACCOUNT_HEADER, &session.account_id)
.header("cookie", build_auth_headers(&session))
.body(Body::empty())
.unwrap();
let (status, json) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["email"], "alice@example.com");
}
#[tokio::test]
async fn wrong_browser_returns_account_expired() {
let store = Arc::new(SessionStore::new(Duration::from_secs(3600)));
let session = create_test_session(&store, "alice@example.com", "hunter2", "abc123");
let wrong_browser_id = store.create_browser();
let router = guarded_router(Arc::clone(&store));
let req = Request::builder()
.uri("/protected")
.header(ACTIVE_ACCOUNT_HEADER, &session.account_id)
.header(
"cookie",
format!(
"rustbox_browser={}; rustbox_session_{}={}",
wrong_browser_id, session.account_id, session.token
),
)
.body(Body::empty())
.unwrap();
let (status, json) = send(router, req).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(json["error"]["code"], "ACCOUNT_SESSION_EXPIRED");
assert_eq!(json["error"]["accountId"], session.account_id);
}
#[tokio::test]
async fn expired_session_returns_account_expired() {
let store = Arc::new(SessionStore::new(Duration::from_millis(50)));
let session = create_test_session(store.as_ref(), "bob@example.com", "pass", "hash");
thread::sleep(Duration::from_millis(100));
let router = guarded_router(store);
let req = Request::builder()
.uri("/protected")
.header(ACTIVE_ACCOUNT_HEADER, &session.account_id)
.header("cookie", build_auth_headers(&session))
.body(Body::empty())
.unwrap();
let (status, json) = send(router, req).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(json["error"]["code"], "ACCOUNT_SESSION_EXPIRED");
assert_eq!(json["error"]["accountId"], session.account_id);
}
#[tokio::test]
async fn cookies_among_multiple_cookies() {
let store = Arc::new(SessionStore::new(Duration::from_secs(3600)));
let session = create_test_session(&store, "multi@example.com", "pass", "hash");
let router = guarded_router(Arc::clone(&store));
let req = Request::builder()
.uri("/protected")
.header(ACTIVE_ACCOUNT_HEADER, &session.account_id)
.header(
"cookie",
format!(
"theme=dark; rustbox_browser={}; lang=en; rustbox_session_{}={}",
session.browser_id, session.account_id, session.token
),
)
.body(Body::empty())
.unwrap();
let (status, json) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["email"], "multi@example.com");
}
#[tokio::test]
async fn wrong_cookie_name_returns_401() {
let store = Arc::new(SessionStore::new(Duration::from_secs(3600)));
let session = create_test_session(&store, "wrong@example.com", "pass", "hash");
let router = guarded_router(Arc::clone(&store));
let req = Request::builder()
.uri("/protected")
.header(ACTIVE_ACCOUNT_HEADER, &session.account_id)
.header(
"cookie",
format!(
"other_browser={}; rustbox_session_{}={}",
session.browser_id, session.account_id, session.token
),
)
.body(Body::empty())
.unwrap();
let (status, _) = send(router, req).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn wrong_account_in_header_returns_account_expired() {
let store = Arc::new(SessionStore::new(Duration::from_secs(3600)));
let session1 = create_test_session(&store, "user1@example.com", "pass", "hash1");
let session2 = create_test_session(&store, "user2@example.com", "pass", "hash2");
let router = guarded_router(Arc::clone(&store));
let req = Request::builder()
.uri("/protected")
.header(ACTIVE_ACCOUNT_HEADER, &session2.account_id)
.header("cookie", build_auth_headers(&session1))
.body(Body::empty())
.unwrap();
let (status, json) = send(router, req).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(json["error"]["code"], "ACCOUNT_SESSION_EXPIRED");
assert_eq!(json["error"]["accountId"], session2.account_id);
}
#[tokio::test]
async fn extract_cookie_value_finds_correct_cookie() {
let req = Request::builder()
.uri("/protected")
.header("cookie", "a=1; b=2; c=3")
.body(Body::empty())
.unwrap();
assert_eq!(extract_cookie_value(&req, "a"), Some("1".to_string()));
assert_eq!(extract_cookie_value(&req, "b"), Some("2".to_string()));
assert_eq!(extract_cookie_value(&req, "c"), Some("3".to_string()));
assert_eq!(extract_cookie_value(&req, "d"), None);
}
#[tokio::test]
async fn extract_active_account_header_works() {
let req = Request::builder()
.uri("/protected")
.header(ACTIVE_ACCOUNT_HEADER, "account-123")
.body(Body::empty())
.unwrap();
assert_eq!(
extract_active_account_header(&req),
Some("account-123".to_string())
);
}
#[tokio::test]
async fn extract_active_account_header_missing() {
let req = Request::builder()
.uri("/protected")
.body(Body::empty())
.unwrap();
assert_eq!(extract_active_account_header(&req), None);
}
#[tokio::test]
async fn extract_active_account_query_works() {
let req = Request::builder()
.uri("/protected?account_id=account-123")
.body(Body::empty())
.unwrap();
assert_eq!(
extract_active_account_query(&req),
Some("account-123".to_string())
);
}
#[tokio::test]
async fn extract_active_account_query_with_encoding() {
let req = Request::builder()
.uri("/protected?account_id=abc%40example.com")
.body(Body::empty())
.unwrap();
assert_eq!(
extract_active_account_query(&req),
Some("abc@example.com".to_string())
);
}
#[tokio::test]
async fn extract_active_account_query_missing() {
let req = Request::builder()
.uri("/protected?other=value")
.body(Body::empty())
.unwrap();
assert_eq!(extract_active_account_query(&req), None);
}
#[tokio::test]
async fn account_id_from_query_param_works() {
let store = Arc::new(SessionStore::new(Duration::from_secs(3600)));
let session = create_test_session(&store, "alice@example.com", "hunter2", "abc123");
let router = guarded_router(Arc::clone(&store));
let req = Request::builder()
.uri(format!("/protected?account_id={}", session.account_id))
.header("cookie", build_auth_headers(&session))
.body(Body::empty())
.unwrap();
let (status, json) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["email"], "alice@example.com");
}
#[tokio::test]
async fn header_takes_precedence_over_query_param() {
let store = Arc::new(SessionStore::new(Duration::from_secs(3600)));
let session1 = create_test_session(&store, "user1@example.com", "pass", "hash1");
let session2 = create_test_session(&store, "user2@example.com", "pass", "hash2");
let router = guarded_router(Arc::clone(&store));
let req = Request::builder()
.uri(format!("/protected?account_id={}", session2.account_id))
.header(ACTIVE_ACCOUNT_HEADER, &session1.account_id)
.header(
"cookie",
format!(
"rustbox_browser={}; rustbox_session_{}={}",
session1.browser_id, session1.account_id, session1.token
),
)
.body(Body::empty())
.unwrap();
let (status, json) = send(router, req).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(json["email"], "user1@example.com");
}
}