Skip to main content

modo/auth/jwt/
source.rs

1use http::request::Parts;
2
3/// Trait for extracting JWT token strings from HTTP requests.
4///
5/// Middleware tries sources in order and uses the first `Some(token)`.
6/// Implement this trait to support custom token locations.
7pub trait TokenSource: Send + Sync {
8    /// Attempts to extract a token string from request parts.
9    /// Returns `None` if this source does not find a token.
10    fn extract(&self, parts: &Parts) -> Option<String>;
11}
12
13/// Extracts a token from the `Authorization: Bearer <token>` header.
14///
15/// Accepts the scheme written as `Bearer` or `bearer` (those two exact
16/// capitalizations, followed by a single space). Other capitalizations
17/// or auth schemes return `None`.
18pub struct BearerSource;
19
20impl TokenSource for BearerSource {
21    fn extract(&self, parts: &Parts) -> Option<String> {
22        let value = parts
23            .headers
24            .get(http::header::AUTHORIZATION)?
25            .to_str()
26            .ok()?;
27        let token = value
28            .strip_prefix("Bearer ")
29            .or_else(|| value.strip_prefix("bearer "))?;
30        if token.is_empty() {
31            return None;
32        }
33        Some(token.to_string())
34    }
35}
36
37/// Extracts a token from a named query parameter (e.g., `?token=xxx`).
38///
39/// The inner `&'static str` is the parameter name to look up.
40pub struct QuerySource(pub &'static str);
41
42impl TokenSource for QuerySource {
43    fn extract(&self, parts: &Parts) -> Option<String> {
44        let query = parts.uri.query()?;
45        for pair in query.split('&') {
46            if let Some((key, value)) = pair.split_once('=')
47                && key == self.0
48                && !value.is_empty()
49            {
50                return Some(value.to_string());
51            }
52        }
53        None
54    }
55}
56
57/// Extracts a token from a named cookie (e.g., `Cookie: jwt=xxx`).
58///
59/// The inner `&'static str` is the cookie name. Parses the raw `Cookie`
60/// header directly — no dependency on session middleware or `axum_extra`.
61pub struct CookieSource(pub &'static str);
62
63impl TokenSource for CookieSource {
64    fn extract(&self, parts: &Parts) -> Option<String> {
65        let cookie_header = parts.headers.get(http::header::COOKIE)?.to_str().ok()?;
66        for cookie in cookie_header.split(';') {
67            let cookie = cookie.trim();
68            if let Some((name, value)) = cookie.split_once('=')
69                && name.trim() == self.0
70                && !value.is_empty()
71            {
72                return Some(value.trim().to_string());
73            }
74        }
75        None
76    }
77}
78
79/// Extracts a token from a custom request header (e.g., `X-API-Token: xxx`).
80///
81/// The inner `&'static str` is the header name.
82pub struct HeaderSource(pub &'static str);
83
84impl TokenSource for HeaderSource {
85    fn extract(&self, parts: &Parts) -> Option<String> {
86        let value = parts.headers.get(self.0)?.to_str().ok()?;
87        if value.is_empty() {
88            return None;
89        }
90        Some(value.to_string())
91    }
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    fn parts_with_header(name: &str, value: &str) -> Parts {
99        let (parts, _) = http::Request::builder()
100            .header(name, value)
101            .body(())
102            .unwrap()
103            .into_parts();
104        parts
105    }
106
107    fn parts_with_uri(uri: &str) -> Parts {
108        let (parts, _) = http::Request::builder()
109            .uri(uri)
110            .body(())
111            .unwrap()
112            .into_parts();
113        parts
114    }
115
116    fn empty_parts() -> Parts {
117        let (parts, _) = http::Request::builder().body(()).unwrap().into_parts();
118        parts
119    }
120
121    // BearerSource tests
122    #[test]
123    fn bearer_extracts_token() {
124        let parts = parts_with_header("Authorization", "Bearer my-token");
125        assert_eq!(BearerSource.extract(&parts), Some("my-token".into()));
126    }
127
128    #[test]
129    fn bearer_case_insensitive_prefix() {
130        let parts = parts_with_header("Authorization", "bearer my-token");
131        assert_eq!(BearerSource.extract(&parts), Some("my-token".into()));
132    }
133
134    #[test]
135    fn bearer_returns_none_when_missing() {
136        assert!(BearerSource.extract(&empty_parts()).is_none());
137    }
138
139    #[test]
140    fn bearer_returns_none_for_non_bearer_scheme() {
141        let parts = parts_with_header("Authorization", "Basic abc123");
142        assert!(BearerSource.extract(&parts).is_none());
143    }
144
145    #[test]
146    fn bearer_returns_none_for_empty_token() {
147        let parts = parts_with_header("Authorization", "Bearer ");
148        assert!(BearerSource.extract(&parts).is_none());
149    }
150
151    // QuerySource tests
152    #[test]
153    fn query_extracts_token() {
154        let parts = parts_with_uri("/path?token=my-token&other=val");
155        assert_eq!(
156            QuerySource("token").extract(&parts),
157            Some("my-token".into())
158        );
159    }
160
161    #[test]
162    fn query_returns_none_when_missing() {
163        let parts = parts_with_uri("/path?other=val");
164        assert!(QuerySource("token").extract(&parts).is_none());
165    }
166
167    #[test]
168    fn query_returns_none_for_empty_value() {
169        let parts = parts_with_uri("/path?token=");
170        assert!(QuerySource("token").extract(&parts).is_none());
171    }
172
173    #[test]
174    fn query_returns_none_without_query_string() {
175        let parts = parts_with_uri("/path");
176        assert!(QuerySource("token").extract(&parts).is_none());
177    }
178
179    // CookieSource tests
180    #[test]
181    fn cookie_extracts_token() {
182        let parts = parts_with_header("Cookie", "jwt=my-token; other=val");
183        assert_eq!(CookieSource("jwt").extract(&parts), Some("my-token".into()));
184    }
185
186    #[test]
187    fn cookie_returns_none_when_missing() {
188        let parts = parts_with_header("Cookie", "other=val");
189        assert!(CookieSource("jwt").extract(&parts).is_none());
190    }
191
192    #[test]
193    fn cookie_returns_none_without_cookie_header() {
194        assert!(CookieSource("jwt").extract(&empty_parts()).is_none());
195    }
196
197    // HeaderSource tests
198    #[test]
199    fn header_extracts_token() {
200        let parts = parts_with_header("X-API-Token", "my-token");
201        assert_eq!(
202            HeaderSource("X-API-Token").extract(&parts),
203            Some("my-token".into())
204        );
205    }
206
207    #[test]
208    fn header_returns_none_when_missing() {
209        assert!(
210            HeaderSource("X-API-Token")
211                .extract(&empty_parts())
212                .is_none()
213        );
214    }
215
216    #[test]
217    fn header_returns_none_for_empty_value() {
218        let parts = parts_with_header("X-API-Token", "");
219        assert!(HeaderSource("X-API-Token").extract(&parts).is_none());
220    }
221}