alibi-core 0.4.1

Shared authentication contracts and backend-independent services for Alibi
Documentation
use super::Middleware;
use crate::error::AuthResult;
use crate::types::{AuthRequest, AuthResponse, HttpMethod};
use async_trait::async_trait;

/// Configuration for CORS middleware.
#[derive(Debug, Clone)]
pub struct CorsConfig {
    /// Allowed origins. An empty list means no CORS headers are added.
    /// Use `["*"]` to allow all origins (not recommended for production).
    pub allowed_origins: Vec<String>,

    /// Allowed HTTP methods. Defaults to common auth methods.
    pub allowed_methods: Vec<String>,

    /// Allowed request headers.
    pub allowed_headers: Vec<String>,

    /// Headers exposed to the browser.
    pub exposed_headers: Vec<String>,

    /// Whether credentials (cookies, authorization) are allowed.
    pub allow_credentials: bool,

    /// Max age for preflight cache (seconds).
    pub max_age: u64,

    /// Whether CORS handling is enabled.
    pub enabled: bool,
}

impl Default for CorsConfig {
    fn default() -> Self {
        Self {
            allowed_origins: Vec::new(),
            allowed_methods: vec![
                "GET".into(),
                "POST".into(),
                "PUT".into(),
                "DELETE".into(),
                "PATCH".into(),
                "OPTIONS".into(),
            ],
            allowed_headers: vec![
                "Content-Type".into(),
                "Authorization".into(),
                "X-Requested-With".into(),
            ],
            exposed_headers: Vec::new(),
            allow_credentials: true,
            max_age: 86400,
            enabled: true,
        }
    }
}

impl CorsConfig {
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    #[must_use]
    pub fn allowed_origin(mut self, origin: impl Into<String>) -> Self {
        self.allowed_origins.push(origin.into());
        self
    }

    #[must_use]
    pub const fn allow_credentials(mut self, allow: bool) -> Self {
        self.allow_credentials = allow;
        self
    }

    #[must_use]
    pub const fn max_age(mut self, seconds: u64) -> Self {
        self.max_age = seconds;
        self
    }

    #[must_use]
    pub const fn enabled(mut self, enabled: bool) -> Self {
        self.enabled = enabled;
        self
    }
}

/// CORS middleware.
///
/// Handles preflight OPTIONS requests and adds CORS response headers.
#[derive(Debug)]
pub struct CorsMiddleware {
    config: CorsConfig,
}

impl CorsMiddleware {
    #[must_use]
    pub const fn new(config: CorsConfig) -> Self {
        Self { config }
    }

    fn is_origin_allowed(&self, origin: &str) -> bool {
        self.config
            .allowed_origins
            .iter()
            .any(|allowed| allowed == "*" || allowed == origin)
    }

    /// The request's origin when it sends one and the policy admits it.
    fn allowed_origin<'a>(&self, req: &'a AuthRequest) -> Option<&'a str> {
        req.headers
            .get("origin")
            .map(String::as_str)
            .filter(|origin| self.is_origin_allowed(origin))
    }

    fn cors_headers(&self, origin: &str) -> Vec<(String, String)> {
        let config = &self.config;
        // A wildcard is not a valid Allow-Origin when credentials are allowed.
        let allow_origin =
            if !config.allow_credentials && config.allowed_origins.iter().any(|o| o == "*") {
                "*"
            } else {
                origin
            };
        let mut headers = vec![(
            "Access-Control-Allow-Origin".to_owned(),
            allow_origin.to_owned(),
        )];
        if config.allow_credentials {
            headers.push(("Access-Control-Allow-Credentials".into(), "true".into()));
        }
        for (name, values) in [
            ("Access-Control-Allow-Methods", &config.allowed_methods),
            ("Access-Control-Allow-Headers", &config.allowed_headers),
            ("Access-Control-Expose-Headers", &config.exposed_headers),
        ] {
            if !values.is_empty() {
                headers.push((name.into(), values.join(", ")));
            }
        }
        headers.push(("Access-Control-Max-Age".into(), config.max_age.to_string()));
        headers
    }
}

#[async_trait]
impl Middleware for CorsMiddleware {
    fn name(&self) -> &'static str {
        "cors"
    }

    async fn before_request(&self, req: &AuthRequest) -> AuthResult<Option<AuthResponse>> {
        if !self.config.enabled || req.method != HttpMethod::Options {
            return Ok(None);
        }
        Ok(self.allowed_origin(req).map(|origin| {
            self.cors_headers(origin)
                .into_iter()
                .fold(AuthResponse::new(204), |response, (key, value)| {
                    response.with_header(key, value)
                })
        }))
    }

    async fn after_request(
        &self,
        req: &AuthRequest,
        mut response: AuthResponse,
    ) -> AuthResult<AuthResponse> {
        if self.config.enabled
            && let Some(origin) = self.allowed_origin(req)
        {
            for (key, value) in self.cors_headers(origin) {
                _ = response.headers.insert(key, value);
            }
        }
        Ok(response)
    }
}

// LCOV_EXCL_START
#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashMap;

    fn make_options(origin: &str) -> AuthRequest {
        let mut headers = HashMap::new();
        headers.insert("origin".to_owned(), origin.to_owned());
        AuthRequest::from_parts(
            HttpMethod::Options,
            "/sign-in/email".to_owned(),
            headers,
            None,
            HashMap::new(),
        )
    }

    fn make_get(origin: &str) -> AuthRequest {
        let mut headers = HashMap::new();
        headers.insert("origin".to_owned(), origin.to_owned());
        AuthRequest::from_parts(
            HttpMethod::Get,
            "/get-session".to_owned(),
            headers,
            None,
            HashMap::new(),
        )
    }

    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
    #[tokio::test]
    async fn test_cors_preflight_allowed() {
        let config = CorsConfig::new().allowed_origin("http://localhost:5173");
        let mw = CorsMiddleware::new(config);
        let req = make_options("http://localhost:5173");

        let resp = mw.before_request(&req).await.unwrap();
        assert!(resp.is_some());
        let resp = resp.unwrap();
        assert_eq!(resp.status, 204);
        assert_eq!(
            resp.headers.get("Access-Control-Allow-Origin").unwrap(),
            "http://localhost:5173"
        );
    }

    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
    #[tokio::test]
    async fn test_cors_preflight_not_allowed() {
        let config = CorsConfig::new().allowed_origin("http://localhost:5173");
        let mw = CorsMiddleware::new(config);
        let req = make_options("http://evil.com");

        let resp = mw.before_request(&req).await.unwrap();
        assert!(resp.is_none()); // No CORS headers added for disallowed origin
    }

    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
    #[tokio::test]
    async fn test_cors_adds_headers_after_request() {
        let config = CorsConfig::new().allowed_origin("http://localhost:5173");
        let mw = CorsMiddleware::new(config);
        let req = make_get("http://localhost:5173");

        let response = AuthResponse::json(200, &serde_json::json!({"ok": true})).unwrap();
        let response = mw.after_request(&req, response).await.unwrap();

        assert_eq!(
            response.headers.get("Access-Control-Allow-Origin").unwrap(),
            "http://localhost:5173"
        );
        assert_eq!(
            response
                .headers
                .get("Access-Control-Allow-Credentials")
                .unwrap(),
            "true"
        );
    }

    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
    #[tokio::test]
    async fn test_cors_no_origin_header() {
        let config = CorsConfig::new().allowed_origin("http://localhost:5173");
        let mw = CorsMiddleware::new(config);
        let req = AuthRequest::from_parts(
            HttpMethod::Get,
            "/get-session".to_owned(),
            HashMap::new(),
            None,
            HashMap::new(),
        );

        assert!(mw.before_request(&req).await.unwrap().is_none());

        let response = AuthResponse::new(200);
        let response = mw.after_request(&req, response).await.unwrap();
        assert!(!response.headers.contains_key("Access-Control-Allow-Origin"));
    }

    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
    #[tokio::test]
    async fn test_cors_wildcard() {
        let config = CorsConfig::new()
            .allowed_origin("*")
            .allow_credentials(false);
        let mw = CorsMiddleware::new(config);
        let req = make_get("http://any-origin.com");

        let response = AuthResponse::new(200);
        let response = mw.after_request(&req, response).await.unwrap();
        assert_eq!(
            response.headers.get("Access-Control-Allow-Origin").unwrap(),
            "*"
        );
    }
}
// LCOV_EXCL_STOP