Skip to main content

modo/auth/session/jwt/
source.rs

1use serde::Deserialize;
2
3use http::request::Parts;
4
5/// Trait for extracting JWT token strings from HTTP requests.
6///
7/// Middleware tries sources in order and uses the first `Some(token)`.
8/// Implement this trait to support custom token locations.
9pub trait TokenSource: Send + Sync {
10    /// Attempts to extract a token string from request parts.
11    /// Returns `None` if this source does not find a token.
12    fn extract(&self, parts: &Parts) -> Option<String>;
13}
14
15/// Extracts a token from the `Authorization: Bearer <token>` header.
16///
17/// The scheme comparison is case-insensitive per RFC 7235 — `Bearer`,
18/// `bearer`, `BEARER`, etc. are all accepted.
19pub struct BearerSource;
20
21impl TokenSource for BearerSource {
22    fn extract(&self, parts: &Parts) -> Option<String> {
23        let value = parts
24            .headers
25            .get(http::header::AUTHORIZATION)?
26            .to_str()
27            .ok()?;
28        let (scheme, rest) = value.split_once(' ')?;
29        if !scheme.eq_ignore_ascii_case("Bearer") {
30            return None;
31        }
32        let token = rest.trim_start();
33        if token.is_empty() {
34            return None;
35        }
36        Some(token.to_string())
37    }
38}
39
40/// Extracts a token from a named query parameter (e.g., `?token=xxx`).
41///
42/// The inner `&'static str` is the parameter name to look up.
43pub struct QuerySource(pub &'static str);
44
45impl TokenSource for QuerySource {
46    fn extract(&self, parts: &Parts) -> Option<String> {
47        let query = parts.uri.query()?;
48        for pair in query.split('&') {
49            if let Some((key, value)) = pair.split_once('=')
50                && key == self.0
51                && !value.is_empty()
52            {
53                return Some(value.to_string());
54            }
55        }
56        None
57    }
58}
59
60/// Extracts a token from a named cookie (e.g., `Cookie: jwt=xxx`).
61///
62/// The inner `&'static str` is the cookie name. Parses the raw `Cookie`
63/// header directly — no dependency on session middleware or `axum_extra`.
64pub struct CookieSource(pub &'static str);
65
66impl TokenSource for CookieSource {
67    fn extract(&self, parts: &Parts) -> Option<String> {
68        let cookie_header = parts.headers.get(http::header::COOKIE)?.to_str().ok()?;
69        for cookie in cookie_header.split(';') {
70            let cookie = cookie.trim();
71            if let Some((name, value)) = cookie.split_once('=')
72                && name.trim() == self.0
73                && !value.is_empty()
74            {
75                return Some(value.trim().to_string());
76            }
77        }
78        None
79    }
80}
81
82/// Extracts a token from a custom request header (e.g., `X-API-Token: xxx`).
83///
84/// The inner `&'static str` is the header name.
85pub struct HeaderSource(pub &'static str);
86
87impl TokenSource for HeaderSource {
88    fn extract(&self, parts: &Parts) -> Option<String> {
89        let value = parts.headers.get(self.0)?.to_str().ok()?;
90        if value.is_empty() {
91            return None;
92        }
93        Some(value.to_string())
94    }
95}
96
97// ── Owned-string variants used internally by TokenSourceConfig::build() ────────
98
99struct OwnedQuerySource(String);
100
101impl TokenSource for OwnedQuerySource {
102    fn extract(&self, parts: &Parts) -> Option<String> {
103        let query = parts.uri.query()?;
104        for pair in query.split('&') {
105            if let Some((key, value)) = pair.split_once('=')
106                && key == self.0
107                && !value.is_empty()
108            {
109                return Some(value.to_string());
110            }
111        }
112        None
113    }
114}
115
116struct OwnedCookieSource(String);
117
118impl TokenSource for OwnedCookieSource {
119    fn extract(&self, parts: &Parts) -> Option<String> {
120        let cookie_header = parts.headers.get(http::header::COOKIE)?.to_str().ok()?;
121        for cookie in cookie_header.split(';') {
122            let cookie = cookie.trim();
123            if let Some((name, value)) = cookie.split_once('=')
124                && name.trim() == self.0
125                && !value.is_empty()
126            {
127                return Some(value.trim().to_string());
128            }
129        }
130        None
131    }
132}
133
134struct OwnedHeaderSource(String);
135
136impl TokenSource for OwnedHeaderSource {
137    fn extract(&self, parts: &Parts) -> Option<String> {
138        let value = parts.headers.get(self.0.as_str())?.to_str().ok()?;
139        if value.is_empty() {
140            return None;
141        }
142        Some(value.to_string())
143    }
144}
145
146// ── TokenSourceConfig ────────────────────────────────────────────────────────
147
148/// YAML-deserialized configuration that selects and constructs a [`TokenSource`].
149///
150/// Used in [`JwtSessionsConfig`](super::config::JwtSessionsConfig) for
151/// `access_source` and `refresh_source`.
152///
153/// # Examples
154///
155/// ```yaml
156/// access_source:
157///   kind: bearer
158///
159/// refresh_source:
160///   kind: cookie
161///   name: refresh_jwt
162/// ```
163#[derive(Debug, Clone, Deserialize)]
164#[serde(tag = "kind", rename_all = "snake_case")]
165pub enum TokenSourceConfig {
166    /// `Authorization: Bearer <token>` header.
167    Bearer,
168    /// Named cookie (e.g., `Cookie: jwt=xxx`).
169    Cookie { name: String },
170    /// Custom request header (e.g., `X-API-Token: xxx`).
171    Header { name: String },
172    /// Named query parameter (e.g., `?token=xxx`).
173    Query { name: String },
174    /// JSON body field — the token is read from the request body during
175    /// session operations; not extracted by [`JwtLayer`](super::middleware::JwtLayer).
176    Body { field: String },
177}
178
179impl TokenSourceConfig {
180    /// Build a boxed [`TokenSource`] for use in middleware.
181    ///
182    /// `Body` variants fall back to `BearerSource` because body extraction is
183    /// handled at the session-handler level, not in request-parts middleware.
184    pub fn build(&self) -> Box<dyn TokenSource> {
185        match self {
186            Self::Bearer => Box::new(BearerSource),
187            Self::Cookie { name } => Box::new(OwnedCookieSource(name.clone())),
188            Self::Header { name } => Box::new(OwnedHeaderSource(name.clone())),
189            Self::Query { name } => Box::new(OwnedQuerySource(name.clone())),
190            // Body tokens are read in the session handler, not by JwtLayer.
191            Self::Body { .. } => Box::new(BearerSource),
192        }
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    fn parts_with_header(name: &str, value: &str) -> Parts {
201        let (parts, _) = http::Request::builder()
202            .header(name, value)
203            .body(())
204            .unwrap()
205            .into_parts();
206        parts
207    }
208
209    fn parts_with_uri(uri: &str) -> Parts {
210        let (parts, _) = http::Request::builder()
211            .uri(uri)
212            .body(())
213            .unwrap()
214            .into_parts();
215        parts
216    }
217
218    fn empty_parts() -> Parts {
219        let (parts, _) = http::Request::builder().body(()).unwrap().into_parts();
220        parts
221    }
222
223    // BearerSource tests
224    #[test]
225    fn bearer_extracts_token() {
226        let parts = parts_with_header("Authorization", "Bearer my-token");
227        assert_eq!(BearerSource.extract(&parts), Some("my-token".into()));
228    }
229
230    #[test]
231    fn bearer_case_insensitive_prefix() {
232        let parts = parts_with_header("Authorization", "bearer my-token");
233        assert_eq!(BearerSource.extract(&parts), Some("my-token".into()));
234    }
235
236    #[test]
237    fn bearer_uppercase_scheme_works() {
238        let parts = parts_with_header("Authorization", "BEARER my-token");
239        assert_eq!(BearerSource.extract(&parts), Some("my-token".into()));
240    }
241
242    #[test]
243    fn bearer_mixed_case_scheme_works() {
244        let parts = parts_with_header("Authorization", "BeArEr my-token");
245        assert_eq!(BearerSource.extract(&parts), Some("my-token".into()));
246    }
247
248    #[test]
249    fn bearer_returns_none_when_missing() {
250        assert!(BearerSource.extract(&empty_parts()).is_none());
251    }
252
253    #[test]
254    fn bearer_returns_none_for_non_bearer_scheme() {
255        let parts = parts_with_header("Authorization", "Basic abc123");
256        assert!(BearerSource.extract(&parts).is_none());
257    }
258
259    #[test]
260    fn bearer_returns_none_for_empty_token() {
261        let parts = parts_with_header("Authorization", "Bearer ");
262        assert!(BearerSource.extract(&parts).is_none());
263    }
264
265    // QuerySource tests
266    #[test]
267    fn query_extracts_token() {
268        let parts = parts_with_uri("/path?token=my-token&other=val");
269        assert_eq!(
270            QuerySource("token").extract(&parts),
271            Some("my-token".into())
272        );
273    }
274
275    #[test]
276    fn query_returns_none_when_missing() {
277        let parts = parts_with_uri("/path?other=val");
278        assert!(QuerySource("token").extract(&parts).is_none());
279    }
280
281    #[test]
282    fn query_returns_none_for_empty_value() {
283        let parts = parts_with_uri("/path?token=");
284        assert!(QuerySource("token").extract(&parts).is_none());
285    }
286
287    #[test]
288    fn query_returns_none_without_query_string() {
289        let parts = parts_with_uri("/path");
290        assert!(QuerySource("token").extract(&parts).is_none());
291    }
292
293    // CookieSource tests
294    #[test]
295    fn cookie_extracts_token() {
296        let parts = parts_with_header("Cookie", "jwt=my-token; other=val");
297        assert_eq!(CookieSource("jwt").extract(&parts), Some("my-token".into()));
298    }
299
300    #[test]
301    fn cookie_returns_none_when_missing() {
302        let parts = parts_with_header("Cookie", "other=val");
303        assert!(CookieSource("jwt").extract(&parts).is_none());
304    }
305
306    #[test]
307    fn cookie_returns_none_without_cookie_header() {
308        assert!(CookieSource("jwt").extract(&empty_parts()).is_none());
309    }
310
311    // HeaderSource tests
312    #[test]
313    fn header_extracts_token() {
314        let parts = parts_with_header("X-API-Token", "my-token");
315        assert_eq!(
316            HeaderSource("X-API-Token").extract(&parts),
317            Some("my-token".into())
318        );
319    }
320
321    #[test]
322    fn header_returns_none_when_missing() {
323        assert!(
324            HeaderSource("X-API-Token")
325                .extract(&empty_parts())
326                .is_none()
327        );
328    }
329
330    #[test]
331    fn header_returns_none_for_empty_value() {
332        let parts = parts_with_header("X-API-Token", "");
333        assert!(HeaderSource("X-API-Token").extract(&parts).is_none());
334    }
335}