Skip to main content

alibi_core/middleware/cors/
mod.rs

1use super::Middleware;
2use crate::error::AuthResult;
3use crate::types::{AuthRequest, AuthResponse, HttpMethod};
4use async_trait::async_trait;
5
6/// Configuration for CORS middleware.
7#[derive(Debug, Clone)]
8pub struct CorsConfig {
9    /// Allowed origins. An empty list means no CORS headers are added.
10    /// Use `["*"]` to allow all origins (not recommended for production).
11    pub allowed_origins: Vec<String>,
12
13    /// Allowed HTTP methods. Defaults to common auth methods.
14    pub allowed_methods: Vec<String>,
15
16    /// Allowed request headers.
17    pub allowed_headers: Vec<String>,
18
19    /// Headers exposed to the browser.
20    pub exposed_headers: Vec<String>,
21
22    /// Whether credentials (cookies, authorization) are allowed.
23    pub allow_credentials: bool,
24
25    /// Max age for preflight cache (seconds).
26    pub max_age: u64,
27
28    /// Whether CORS handling is enabled.
29    pub enabled: bool,
30}
31
32impl Default for CorsConfig {
33    fn default() -> Self {
34        Self {
35            allowed_origins: Vec::new(),
36            allowed_methods: vec![
37                "GET".into(),
38                "POST".into(),
39                "PUT".into(),
40                "DELETE".into(),
41                "PATCH".into(),
42                "OPTIONS".into(),
43            ],
44            allowed_headers: vec![
45                "Content-Type".into(),
46                "Authorization".into(),
47                "X-Requested-With".into(),
48            ],
49            exposed_headers: Vec::new(),
50            allow_credentials: true,
51            max_age: 86400,
52            enabled: true,
53        }
54    }
55}
56
57impl CorsConfig {
58    #[must_use]
59    pub fn new() -> Self {
60        Self::default()
61    }
62
63    #[must_use]
64    pub fn allowed_origin(mut self, origin: impl Into<String>) -> Self {
65        self.allowed_origins.push(origin.into());
66        self
67    }
68
69    #[must_use]
70    pub const fn allow_credentials(mut self, allow: bool) -> Self {
71        self.allow_credentials = allow;
72        self
73    }
74
75    #[must_use]
76    pub const fn max_age(mut self, seconds: u64) -> Self {
77        self.max_age = seconds;
78        self
79    }
80
81    #[must_use]
82    pub const fn enabled(mut self, enabled: bool) -> Self {
83        self.enabled = enabled;
84        self
85    }
86}
87
88/// CORS middleware.
89///
90/// Handles preflight OPTIONS requests and adds CORS response headers.
91#[derive(Debug)]
92pub struct CorsMiddleware {
93    config: CorsConfig,
94}
95
96impl CorsMiddleware {
97    #[must_use]
98    pub const fn new(config: CorsConfig) -> Self {
99        Self { config }
100    }
101
102    fn is_origin_allowed(&self, origin: &str) -> bool {
103        self.config
104            .allowed_origins
105            .iter()
106            .any(|allowed| allowed == "*" || allowed == origin)
107    }
108
109    /// The request's origin when it sends one and the policy admits it.
110    fn allowed_origin<'a>(&self, req: &'a AuthRequest) -> Option<&'a str> {
111        req.headers
112            .get("origin")
113            .map(String::as_str)
114            .filter(|origin| self.is_origin_allowed(origin))
115    }
116
117    fn cors_headers(&self, origin: &str) -> Vec<(String, String)> {
118        let config = &self.config;
119        // A wildcard is not a valid Allow-Origin when credentials are allowed.
120        let allow_origin =
121            if !config.allow_credentials && config.allowed_origins.iter().any(|o| o == "*") {
122                "*"
123            } else {
124                origin
125            };
126        let mut headers = vec![(
127            "Access-Control-Allow-Origin".to_owned(),
128            allow_origin.to_owned(),
129        )];
130        if config.allow_credentials {
131            headers.push(("Access-Control-Allow-Credentials".into(), "true".into()));
132        }
133        for (name, values) in [
134            ("Access-Control-Allow-Methods", &config.allowed_methods),
135            ("Access-Control-Allow-Headers", &config.allowed_headers),
136            ("Access-Control-Expose-Headers", &config.exposed_headers),
137        ] {
138            if !values.is_empty() {
139                headers.push((name.into(), values.join(", ")));
140            }
141        }
142        headers.push(("Access-Control-Max-Age".into(), config.max_age.to_string()));
143        headers
144    }
145}
146
147#[async_trait]
148impl Middleware for CorsMiddleware {
149    fn name(&self) -> &'static str {
150        "cors"
151    }
152
153    async fn before_request(&self, req: &AuthRequest) -> AuthResult<Option<AuthResponse>> {
154        if !self.config.enabled || req.method != HttpMethod::Options {
155            return Ok(None);
156        }
157        Ok(self.allowed_origin(req).map(|origin| {
158            self.cors_headers(origin)
159                .into_iter()
160                .fold(AuthResponse::new(204), |response, (key, value)| {
161                    response.with_header(key, value)
162                })
163        }))
164    }
165
166    async fn after_request(
167        &self,
168        req: &AuthRequest,
169        mut response: AuthResponse,
170    ) -> AuthResult<AuthResponse> {
171        if self.config.enabled
172            && let Some(origin) = self.allowed_origin(req)
173        {
174            for (key, value) in self.cors_headers(origin) {
175                _ = response.headers.insert(key, value);
176            }
177        }
178        Ok(response)
179    }
180}
181
182// LCOV_EXCL_START
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use std::collections::HashMap;
187
188    fn make_options(origin: &str) -> AuthRequest {
189        let mut headers = HashMap::new();
190        headers.insert("origin".to_owned(), origin.to_owned());
191        AuthRequest::from_parts(
192            HttpMethod::Options,
193            "/sign-in/email".to_owned(),
194            headers,
195            None,
196            HashMap::new(),
197        )
198    }
199
200    fn make_get(origin: &str) -> AuthRequest {
201        let mut headers = HashMap::new();
202        headers.insert("origin".to_owned(), origin.to_owned());
203        AuthRequest::from_parts(
204            HttpMethod::Get,
205            "/get-session".to_owned(),
206            headers,
207            None,
208            HashMap::new(),
209        )
210    }
211
212    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
213    #[tokio::test]
214    async fn test_cors_preflight_allowed() {
215        let config = CorsConfig::new().allowed_origin("http://localhost:5173");
216        let mw = CorsMiddleware::new(config);
217        let req = make_options("http://localhost:5173");
218
219        let resp = mw.before_request(&req).await.unwrap();
220        assert!(resp.is_some());
221        let resp = resp.unwrap();
222        assert_eq!(resp.status, 204);
223        assert_eq!(
224            resp.headers.get("Access-Control-Allow-Origin").unwrap(),
225            "http://localhost:5173"
226        );
227    }
228
229    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
230    #[tokio::test]
231    async fn test_cors_preflight_not_allowed() {
232        let config = CorsConfig::new().allowed_origin("http://localhost:5173");
233        let mw = CorsMiddleware::new(config);
234        let req = make_options("http://evil.com");
235
236        let resp = mw.before_request(&req).await.unwrap();
237        assert!(resp.is_none()); // No CORS headers added for disallowed origin
238    }
239
240    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
241    #[tokio::test]
242    async fn test_cors_adds_headers_after_request() {
243        let config = CorsConfig::new().allowed_origin("http://localhost:5173");
244        let mw = CorsMiddleware::new(config);
245        let req = make_get("http://localhost:5173");
246
247        let response = AuthResponse::json(200, &serde_json::json!({"ok": true})).unwrap();
248        let response = mw.after_request(&req, response).await.unwrap();
249
250        assert_eq!(
251            response.headers.get("Access-Control-Allow-Origin").unwrap(),
252            "http://localhost:5173"
253        );
254        assert_eq!(
255            response
256                .headers
257                .get("Access-Control-Allow-Credentials")
258                .unwrap(),
259            "true"
260        );
261    }
262
263    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
264    #[tokio::test]
265    async fn test_cors_no_origin_header() {
266        let config = CorsConfig::new().allowed_origin("http://localhost:5173");
267        let mw = CorsMiddleware::new(config);
268        let req = AuthRequest::from_parts(
269            HttpMethod::Get,
270            "/get-session".to_owned(),
271            HashMap::new(),
272            None,
273            HashMap::new(),
274        );
275
276        assert!(mw.before_request(&req).await.unwrap().is_none());
277
278        let response = AuthResponse::new(200);
279        let response = mw.after_request(&req, response).await.unwrap();
280        assert!(!response.headers.contains_key("Access-Control-Allow-Origin"));
281    }
282
283    // Rust-specific surface: Rust middleware implementations are library-specific behavior with no direct TS analogue.
284    #[tokio::test]
285    async fn test_cors_wildcard() {
286        let config = CorsConfig::new()
287            .allowed_origin("*")
288            .allow_credentials(false);
289        let mw = CorsMiddleware::new(config);
290        let req = make_get("http://any-origin.com");
291
292        let response = AuthResponse::new(200);
293        let response = mw.after_request(&req, response).await.unwrap();
294        assert_eq!(
295            response.headers.get("Access-Control-Allow-Origin").unwrap(),
296            "*"
297        );
298    }
299}
300// LCOV_EXCL_STOP