riley_cms_api/
middleware.rs

1//! Middleware for riley-cms-api
2//!
3//! Authentication middleware for protected endpoints.
4
5use axum::{
6    extract::{Request, State},
7    http::header,
8    middleware::Next,
9    response::Response,
10};
11use sha2::{Digest, Sha256};
12use std::sync::Arc;
13use subtle::ConstantTimeEq;
14
15use crate::AppState;
16
17/// Authentication status for the current request.
18///
19/// This is inserted into request extensions by the auth middleware
20/// and can be extracted by handlers to make authorization decisions.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum AuthStatus {
23    /// Unauthenticated public request
24    Public,
25    /// Authenticated admin request (valid Bearer token provided)
26    Admin,
27}
28
29/// Authentication middleware that validates Bearer tokens.
30///
31/// This middleware runs on every request and:
32/// 1. Checks for an `Authorization: Bearer <token>` header
33/// 2. Validates the token against the configured `auth.api_token`
34/// 3. Sets `AuthStatus::Admin` if valid, `AuthStatus::Public` otherwise
35/// 4. Inserts the status into request extensions for handlers to check
36pub async fn auth_middleware(
37    State(state): State<Arc<AppState>>,
38    mut request: Request,
39    next: Next,
40) -> Response {
41    let mut auth_status = AuthStatus::Public;
42
43    // Check for configured API token
44    if let Some(ref auth_config) = state.config.auth
45        && let Some(ref token_config) = auth_config.api_token
46    {
47        // Resolve the token (supports "env:VAR_NAME" syntax)
48        match token_config.resolve() {
49            Ok(expected_token) => {
50                if expected_token.is_empty() {
51                    tracing::warn!("API token resolves to empty string. Admin auth disabled.");
52                } else {
53                    // Check Authorization header for Bearer token
54                    if let Some(auth_header) = request.headers().get(header::AUTHORIZATION)
55                        && let Ok(auth_str) = auth_header.to_str()
56                        && let Some(provided_token) = auth_str.strip_prefix("Bearer ")
57                    {
58                        // Hash both tokens before comparing to prevent
59                        // leaking token length via timing side-channel.
60                        // SHA-256 produces fixed 32-byte hashes regardless
61                        // of input length.
62                        let provided_hash = Sha256::digest(provided_token.trim().as_bytes());
63                        let expected_hash = Sha256::digest(expected_token.as_bytes());
64                        if provided_hash.ct_eq(&expected_hash).into() {
65                            auth_status = AuthStatus::Admin;
66                        }
67                    }
68                }
69            }
70            Err(e) => {
71                tracing::warn!("Failed to resolve API token: {}. Admin auth disabled.", e);
72            }
73        }
74    }
75
76    // Insert status into extensions so handlers can read it
77    request.extensions_mut().insert(auth_status);
78
79    next.run(request).await
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn test_auth_status_equality() {
88        assert_eq!(AuthStatus::Public, AuthStatus::Public);
89        assert_eq!(AuthStatus::Admin, AuthStatus::Admin);
90        assert_ne!(AuthStatus::Public, AuthStatus::Admin);
91    }
92}