Skip to main content

scope_web/
auth.rs

1//! Optional bearer token authentication middleware.
2
3use axum::{
4    body::Body,
5    http::{Request, StatusCode},
6    middleware::Next,
7    response::Response,
8};
9
10/// Middleware that validates bearer tokens when configured.
11///
12/// If `expected_token` is `None`, all requests pass through (no-op).
13/// If `expected_token` is `Some`, requests must include a matching
14/// `Authorization: Bearer <token>` header or receive a 401.
15pub async fn auth_middleware(
16    request: Request<Body>,
17    next: Next,
18    expected_token: Option<String>,
19) -> Result<Response, StatusCode> {
20    // No token configured — pass through
21    let Some(expected) = expected_token else {
22        return Ok(next.run(request).await);
23    };
24
25    // Check Authorization header
26    let auth_header = request
27        .headers()
28        .get("authorization")
29        .and_then(|v| v.to_str().ok());
30
31    match auth_header {
32        Some(header) if header.starts_with("Bearer ") => {
33            let token = &header[7..];
34            // Use constant-time comparison to prevent timing attacks
35            if constant_time_eq(token.as_bytes(), expected.as_bytes()) {
36                Ok(next.run(request).await)
37            } else {
38                Err(StatusCode::UNAUTHORIZED)
39            }
40        }
41        _ => Err(StatusCode::UNAUTHORIZED),
42    }
43}
44
45/// Constant-time byte comparison to prevent timing side channels.
46fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
47    if a.len() != b.len() {
48        return false;
49    }
50    let mut result = 0u8;
51    for (x, y) in a.iter().zip(b.iter()) {
52        result |= x ^ y;
53    }
54    result == 0
55}
56
57// ============================================================================
58// Unit Tests
59// ============================================================================
60
61#[cfg(test)]
62mod tests {
63    use super::*;
64    use axum::{Router, body::Body, middleware, routing::get};
65    use tower::ServiceExt;
66
67    /// Helper: build a tiny router that uses our auth middleware.
68    fn test_app(token: Option<String>) -> Router {
69        let handler = get(|| async { "ok" });
70        Router::new()
71            .route("/api/test", handler)
72            .layer(middleware::from_fn(
73                move |req: Request<Body>, next: Next| {
74                    let t = token.clone();
75                    async move { auth_middleware(req, next, t).await }
76                },
77            ))
78    }
79
80    #[tokio::test]
81    async fn test_no_token_configured_passes() {
82        let app = test_app(None);
83        let req = Request::builder()
84            .uri("/api/test")
85            .body(Body::empty())
86            .unwrap();
87        let resp = app.oneshot(req).await.unwrap();
88        assert_eq!(resp.status(), StatusCode::OK);
89    }
90
91    #[tokio::test]
92    async fn test_correct_token_passes() {
93        let app = test_app(Some("secret123".into()));
94        let req = Request::builder()
95            .uri("/api/test")
96            .header("authorization", "Bearer secret123")
97            .body(Body::empty())
98            .unwrap();
99        let resp = app.oneshot(req).await.unwrap();
100        assert_eq!(resp.status(), StatusCode::OK);
101    }
102
103    #[tokio::test]
104    async fn test_wrong_token_rejected() {
105        let app = test_app(Some("secret123".into()));
106        let req = Request::builder()
107            .uri("/api/test")
108            .header("authorization", "Bearer wrong-token")
109            .body(Body::empty())
110            .unwrap();
111        let resp = app.oneshot(req).await.unwrap();
112        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
113    }
114
115    #[tokio::test]
116    async fn test_missing_auth_header_rejected() {
117        let app = test_app(Some("secret123".into()));
118        let req = Request::builder()
119            .uri("/api/test")
120            .body(Body::empty())
121            .unwrap();
122        let resp = app.oneshot(req).await.unwrap();
123        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
124    }
125
126    #[tokio::test]
127    async fn test_malformed_auth_header_rejected() {
128        let app = test_app(Some("secret123".into()));
129
130        // "Basic" instead of "Bearer"
131        let req = Request::builder()
132            .uri("/api/test")
133            .header("authorization", "Basic secret123")
134            .body(Body::empty())
135            .unwrap();
136        let resp = app.oneshot(req).await.unwrap();
137        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
138    }
139
140    #[test]
141    fn test_constant_time_eq_equal() {
142        assert!(constant_time_eq(b"hello", b"hello"));
143    }
144
145    #[test]
146    fn test_constant_time_eq_unequal() {
147        assert!(!constant_time_eq(b"hello", b"world"));
148    }
149
150    #[test]
151    fn test_constant_time_eq_different_lengths() {
152        assert!(!constant_time_eq(b"short", b"longer"));
153    }
154}