Skip to main content

ws_kit/
extractor.rs

1//! Token extraction for WebSocket authentication.
2//!
3//! Extracts bearer tokens from HTTP upgrade requests using a configurable set
4//! of [`TokenSourceKind`] strategies. Supports `Authorization: Bearer <token>`,
5//! query parameters (`?token=` / `?access_token=`), and `Cookie` headers.
6
7use thiserror::Error;
8
9#[cfg(feature = "axum")]
10use http::request::Parts;
11
12/// Where to look for a token.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum TokenSourceKind {
15    /// `Authorization: Bearer <token>` header.
16    AuthorizationHeader,
17    /// Query parameter with the given name (e.g. `token`, `access_token`).
18    QueryParam(String),
19    /// Cookie with the given name.
20    Cookie(String),
21}
22
23/// Error during authentication extraction/validation.
24#[derive(Debug, Error, Clone, PartialEq, Eq)]
25pub enum WsAuthError {
26    /// No token found in any configured source.
27    #[error("authentication token missing")]
28    Missing,
29
30    /// Token was found but is invalid.
31    #[error("authentication token invalid")]
32    Invalid,
33
34    /// Token is expired (if expiry is checked downstream).
35    #[error("authentication token expired")]
36    Expired,
37}
38
39/// Extracts tokens from HTTP request parts according to configured sources.
40#[derive(Debug, Clone)]
41pub struct TokenExtractor {
42    sources: Vec<TokenSourceKind>,
43}
44
45impl Default for TokenExtractor {
46    fn default() -> Self {
47        Self {
48            sources: vec![
49                TokenSourceKind::AuthorizationHeader,
50                TokenSourceKind::QueryParam("token".to_string()),
51                TokenSourceKind::QueryParam("access_token".to_string()),
52            ],
53        }
54    }
55}
56
57impl TokenExtractor {
58    /// Create a new extractor with the given ordered sources.
59    pub fn new(sources: Vec<TokenSourceKind>) -> Self {
60        Self { sources }
61    }
62
63    /// Bearer-only extractor.
64    pub fn bearer_only() -> Self {
65        Self {
66            sources: vec![TokenSourceKind::AuthorizationHeader],
67        }
68    }
69
70    /// Add a source.
71    pub fn with_source(mut self, src: TokenSourceKind) -> Self {
72        self.sources.push(src);
73        self
74    }
75
76    /// Ordered sources.
77    pub fn sources(&self) -> &[TokenSourceKind] {
78        &self.sources
79    }
80
81    /// Extract the first token found according to source order.
82    ///
83    /// When the `axum` feature is enabled the first argument is `&http::request::Parts`
84    /// (from `axum`'s request). The second argument is the raw query string
85    /// (e.g. `"token=abc&foo=bar"` without leading `?`).
86    ///
87    /// Returns `None` if no token was found in any source.
88    #[cfg(feature = "axum")]
89    pub fn extract_token(&self, parts: &Parts, query: &str) -> Option<String> {
90        for src in &self.sources {
91            match src {
92                TokenSourceKind::AuthorizationHeader => {
93                    if let Some(v) = Self::extract_bearer(parts) {
94                        return Some(v);
95                    }
96                }
97                TokenSourceKind::QueryParam(name) => {
98                    if let Some(v) = Self::extract_query_param(query, name) {
99                        return Some(v);
100                    }
101                }
102                TokenSourceKind::Cookie(name) => {
103                    if let Some(v) = Self::extract_cookie(parts, name) {
104                        return Some(v);
105                    }
106                }
107            }
108        }
109        None
110    }
111
112    /// Extract token without `Parts` (query and cookie header strings only).
113    ///
114    /// Useful when `axum` is not enabled or for tests.
115    pub fn extract_from_parts(
116        &self,
117        auth_header: Option<&str>,
118        cookie_header: Option<&str>,
119        query: &str,
120    ) -> Option<String> {
121        for src in &self.sources {
122            match src {
123                TokenSourceKind::AuthorizationHeader => {
124                    if let Some(v) = auth_header.and_then(Self::parse_bearer) {
125                        if !v.is_empty() {
126                            return Some(v);
127                        }
128                    }
129                }
130                TokenSourceKind::QueryParam(name) => {
131                    if let Some(v) = Self::extract_query_param(query, name) {
132                        return Some(v);
133                    }
134                }
135                TokenSourceKind::Cookie(name) => {
136                    if let Some(v) = cookie_header.and_then(|c| Self::parse_cookie(c, name)) {
137                        return Some(v);
138                    }
139                }
140            }
141        }
142        None
143    }
144
145    /// Validate presence of token, returning a typed error.
146    #[cfg(feature = "axum")]
147    pub fn require_token(&self, parts: &Parts, query: &str) -> Result<String, WsAuthError> {
148        self.extract_token(parts, query).ok_or(WsAuthError::Missing)
149    }
150
151    #[cfg(feature = "axum")]
152    fn extract_bearer(parts: &Parts) -> Option<String> {
153        let val = parts.headers.get(http::header::AUTHORIZATION)?;
154        let s = val.to_str().ok()?;
155        Self::parse_bearer(s)
156    }
157
158    fn parse_bearer(s: &str) -> Option<String> {
159        // Case-insensitive "Bearer " prefix. Byte slicing is safe here: the
160        // prefix bytes and the single separating space are all ASCII, and
161        // `is_char_boundary(7)` guarantees `s[7..]` starts on a code point
162        // edge. A multibyte header value that merely happens to be >= 7
163        // *bytes* long but has no boundary at 7 is rejected, not sliced.
164        let bytes = s.as_bytes();
165        if bytes.len() < 7 {
166            return None;
167        }
168        if !bytes[..7].eq_ignore_ascii_case(b"bearer ") {
169            return None;
170        }
171        if !s.is_char_boundary(7) {
172            return None;
173        }
174        let token = s[7..].trim();
175        if token.is_empty() {
176            None
177        } else {
178            Some(token.to_string())
179        }
180    }
181
182    fn extract_query_param(query: &str, name: &str) -> Option<String> {
183        let query = query.trim_start_matches('?');
184        if query.is_empty() {
185            return None;
186        }
187        for pair in query.split('&') {
188            if pair.is_empty() {
189                continue;
190            }
191            let mut split = pair.splitn(2, '=');
192            let k = split.next().unwrap_or("");
193            let v = split.next().unwrap_or("");
194            // URL-decode k and v minimally: '+' -> ' ', %XX
195            let k_decoded = url_decode(k);
196            if k_decoded == name {
197                let v_decoded = url_decode(v);
198                if !v_decoded.is_empty() {
199                    return Some(v_decoded);
200                }
201            }
202        }
203        None
204    }
205
206    #[cfg(feature = "axum")]
207    fn extract_cookie(parts: &Parts, name: &str) -> Option<String> {
208        let val = parts.headers.get(http::header::COOKIE)?;
209        let s = val.to_str().ok()?;
210        Self::parse_cookie(s, name)
211    }
212
213    fn parse_cookie(header: &str, name: &str) -> Option<String> {
214        for part in header.split(';') {
215            let part = part.trim();
216            let mut split = part.splitn(2, '=');
217            let k = split.next().unwrap_or("").trim();
218            let v = split.next().unwrap_or("").trim();
219            // Trim optional quotes
220            let v = v.trim_matches('"');
221            if k == name && !v.is_empty() {
222                return Some(url_decode(v));
223            }
224        }
225        None
226    }
227}
228
229fn url_decode(s: &str) -> String {
230    let mut out = String::with_capacity(s.len());
231    let mut chars = s.chars().peekable();
232    while let Some(c) = chars.next() {
233        match c {
234            '+' => out.push(' '),
235            '%' => {
236                let hi = chars.next();
237                let lo = chars.next();
238                if let (Some(h), Some(l)) = (hi, lo) {
239                    if let (Some(hv), Some(lv)) = (hex_val(h), hex_val(l)) {
240                        out.push((hv * 16 + lv) as char);
241                    } else {
242                        out.push('%');
243                        out.push(h);
244                        out.push(l);
245                    }
246                } else {
247                    out.push('%');
248                    if let Some(h) = hi {
249                        out.push(h);
250                    }
251                    if let Some(l) = lo {
252                        out.push(l);
253                    }
254                }
255            }
256            other => out.push(other),
257        }
258    }
259    out
260}
261
262fn hex_val(c: char) -> Option<u8> {
263    match c {
264        '0'..='9' => Some(c as u8 - b'0'),
265        'a'..='f' => Some(c as u8 - b'a' + 10),
266        'A'..='F' => Some(c as u8 - b'A' + 10),
267        _ => None,
268    }
269}
270
271// Tests exercise failure paths and invariants directly; unwrap/expect,
272// slicing, and panicking asserts are acceptable here — violations
273// surface as test failures, not production panics.
274#[allow(
275    clippy::unwrap_used,
276    clippy::expect_used,
277    clippy::indexing_slicing,
278    clippy::panic
279)]
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn parse_bearer_multibyte_no_panic() {
286        // Regression (REQ-WSKIT-001 fuzz finding): a >= 7-byte header value
287        // whose 7th byte lands mid-code-point must be rejected, never panic.
288        let evil = "Beéerer x"; // 9 bytes, char boundary at 7? 'é' is 2 bytes — boundary check must gate
289        assert_eq!(TokenExtractor::parse_bearer(evil), None);
290        let evil2 = "Bearération token"; // 7-byte prefix matches "Bearer " in ASCII bytes? no — 'é' at byte 4
291        assert_eq!(TokenExtractor::parse_bearer(evil2), None);
292        // Boundary at 7 with ASCII prefix: still works.
293        assert_eq!(
294            TokenExtractor::parse_bearer("Bearer café"),
295            Some("café".to_string())
296        );
297    }
298
299    #[test]
300    fn parse_bearer_ok() {
301        assert_eq!(
302            TokenExtractor::parse_bearer("Bearer abc123"),
303            Some("abc123".to_string())
304        );
305        assert_eq!(
306            TokenExtractor::parse_bearer("bearer abc123"),
307            Some("abc123".to_string())
308        );
309        assert_eq!(
310            TokenExtractor::parse_bearer("BEARER abc123"),
311            Some("abc123".to_string())
312        );
313        assert_eq!(
314            TokenExtractor::parse_bearer("Bearer   spaced  "),
315            Some("spaced".to_string())
316        );
317    }
318
319    #[test]
320    fn parse_bearer_fail() {
321        assert_eq!(TokenExtractor::parse_bearer("Basic abc"), None);
322        assert_eq!(TokenExtractor::parse_bearer("Bearer "), None);
323        assert_eq!(TokenExtractor::parse_bearer("Bearer"), None);
324        assert_eq!(TokenExtractor::parse_bearer(""), None);
325    }
326
327    #[test]
328    fn query_param_extraction() {
329        assert_eq!(
330            TokenExtractor::extract_query_param("token=abc123&foo=bar", "token"),
331            Some("abc123".to_string())
332        );
333        assert_eq!(
334            TokenExtractor::extract_query_param("?access_token=xyz", "access_token"),
335            Some("xyz".to_string())
336        );
337        assert_eq!(
338            TokenExtractor::extract_query_param("foo=1&token=second", "token"),
339            Some("second".to_string())
340        );
341        assert_eq!(TokenExtractor::extract_query_param("", "token"), None);
342        assert_eq!(
343            TokenExtractor::extract_query_param("token=&foo=bar", "token"),
344            None
345        );
346    }
347
348    #[test]
349    fn query_url_decode() {
350        assert_eq!(
351            TokenExtractor::extract_query_param("token=hello%20world", "token"),
352            Some("hello world".to_string())
353        );
354        assert_eq!(
355            TokenExtractor::extract_query_param("token=a%2Bb", "token"),
356            Some("a+b".to_string())
357        );
358    }
359
360    #[test]
361    fn cookie_extraction() {
362        assert_eq!(
363            TokenExtractor::parse_cookie("session=abc; token=xyz; foo=bar", "token"),
364            Some("xyz".to_string())
365        );
366        assert_eq!(
367            TokenExtractor::parse_cookie("token=hello%20world", "token"),
368            Some("hello world".to_string())
369        );
370        assert_eq!(TokenExtractor::parse_cookie("a=b; c=d", "token"), None);
371    }
372
373    #[test]
374    fn extractor_order_header_first() {
375        let ex = TokenExtractor::default();
376        let token = ex.extract_from_parts(Some("Bearer header_tok"), None, "token=query_tok");
377        assert_eq!(token, Some("header_tok".to_string()));
378    }
379
380    #[test]
381    fn extractor_fallback_to_query() {
382        let ex = TokenExtractor::default();
383        let token = ex.extract_from_parts(None, None, "token=query_tok");
384        assert_eq!(token, Some("query_tok".to_string()));
385    }
386
387    #[test]
388    fn extractor_fallback_access_token() {
389        let ex = TokenExtractor::default();
390        let token = ex.extract_from_parts(None, None, "access_token=at");
391        assert_eq!(token, Some("at".to_string()));
392    }
393
394    #[test]
395    fn extractor_cookie_source() {
396        let ex = TokenExtractor::new(vec![TokenSourceKind::Cookie("session".to_string())]);
397        let token = ex.extract_from_parts(None, Some("session=mycookie"), "");
398        assert_eq!(token, Some("mycookie".to_string()));
399    }
400
401    #[test]
402    fn extractor_none_when_missing() {
403        let ex = TokenExtractor::default();
404        assert_eq!(ex.extract_from_parts(None, None, ""), None);
405    }
406
407    #[cfg(feature = "axum")]
408    mod axum_tests {
409        use super::*;
410        use http::{HeaderMap, Request};
411
412        fn parts_with(headers: HeaderMap, uri: &str) -> http::request::Parts {
413            let req = Request::builder().uri(uri).body(()).unwrap();
414            let (mut parts, _) = req.into_parts();
415            parts.headers = headers;
416            parts
417        }
418
419        #[test]
420        fn axum_bearer_extraction() {
421            let mut headers = HeaderMap::new();
422            headers.insert(
423                http::header::AUTHORIZATION,
424                "Bearer axum_tok".parse().unwrap(),
425            );
426            let parts = parts_with(headers, "/ws?token=ignored");
427            let ex = TokenExtractor::default();
428            assert_eq!(
429                ex.extract_token(&parts, "token=ignored"),
430                Some("axum_tok".to_string())
431            );
432        }
433
434        #[test]
435        fn axum_query_fallback() {
436            let parts = parts_with(HeaderMap::new(), "/ws?token=qtoken");
437            let ex = TokenExtractor::default();
438            assert_eq!(
439                ex.extract_token(&parts, "token=qtoken"),
440                Some("qtoken".to_string())
441            );
442        }
443
444        #[test]
445        fn axum_cookie() {
446            let mut headers = HeaderMap::new();
447            headers.insert(http::header::COOKIE, "tok=cookie_val".parse().unwrap());
448            let parts = parts_with(headers, "/ws");
449            let ex = TokenExtractor::new(vec![TokenSourceKind::Cookie("tok".to_string())]);
450            assert_eq!(ex.extract_token(&parts, ""), Some("cookie_val".to_string()));
451        }
452    }
453}