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