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
169            .get(..7)
170            .is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"bearer "))
171        {
172            return None;
173        }
174        if !s.is_char_boundary(7) {
175            return None;
176        }
177        let token = s[7..].trim();
178        if token.is_empty() {
179            None
180        } else {
181            Some(token.to_string())
182        }
183    }
184
185    fn extract_query_param(query: &str, name: &str) -> Option<String> {
186        let query = query.trim_start_matches('?');
187        if query.is_empty() {
188            return None;
189        }
190        for pair in query.split('&') {
191            if pair.is_empty() {
192                continue;
193            }
194            let mut split = pair.splitn(2, '=');
195            let k = split.next().unwrap_or("");
196            let v = split.next().unwrap_or("");
197            // URL-decode k and v minimally: '+' -> ' ', %XX
198            let k_decoded = url_decode(k);
199            if k_decoded == name {
200                let v_decoded = url_decode(v);
201                if !v_decoded.is_empty() {
202                    return Some(v_decoded);
203                }
204            }
205        }
206        None
207    }
208
209    #[cfg(feature = "axum")]
210    fn extract_cookie(parts: &Parts, name: &str) -> Option<String> {
211        let val = parts.headers.get(http::header::COOKIE)?;
212        let s = val.to_str().ok()?;
213        Self::parse_cookie(s, name)
214    }
215
216    fn parse_cookie(header: &str, name: &str) -> Option<String> {
217        for part in header.split(';') {
218            let part = part.trim();
219            let mut split = part.splitn(2, '=');
220            let k = split.next().unwrap_or("").trim();
221            let v = split.next().unwrap_or("").trim();
222            // Trim optional quotes
223            let v = v.trim_matches('"');
224            if k == name && !v.is_empty() {
225                return Some(url_decode(v));
226            }
227        }
228        None
229    }
230}
231
232fn url_decode(s: &str) -> String {
233    let mut out = String::with_capacity(s.len());
234    let mut chars = s.chars().peekable();
235    while let Some(c) = chars.next() {
236        match c {
237            '+' => out.push(' '),
238            '%' => {
239                let hi = chars.next();
240                let lo = chars.next();
241                if let (Some(h), Some(l)) = (hi, lo) {
242                    if let (Some(hv), Some(lv)) = (hex_val(h), hex_val(l)) {
243                        out.push((hv * 16 + lv) as char);
244                    } else {
245                        out.push('%');
246                        out.push(h);
247                        out.push(l);
248                    }
249                } else {
250                    out.push('%');
251                    if let Some(h) = hi {
252                        out.push(h);
253                    }
254                    if let Some(l) = lo {
255                        out.push(l);
256                    }
257                }
258            }
259            other => out.push(other),
260        }
261    }
262    out
263}
264
265fn hex_val(c: char) -> Option<u8> {
266    match c {
267        '0'..='9' => Some(c as u8 - b'0'),
268        'a'..='f' => Some(c as u8 - b'a' + 10),
269        'A'..='F' => Some(c as u8 - b'A' + 10),
270        _ => None,
271    }
272}
273
274// Tests exercise failure paths and invariants directly; unwrap/expect,
275// slicing, and panicking asserts are acceptable here — violations
276// surface as test failures, not production panics.
277#[allow(
278    clippy::unwrap_used,
279    clippy::expect_used,
280    clippy::indexing_slicing,
281    clippy::panic
282)]
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn parse_bearer_multibyte_no_panic() {
289        // Regression (REQ-WSKIT-001 fuzz finding): a >= 7-byte header value
290        // whose 7th byte lands mid-code-point must be rejected, never panic.
291        let evil = "Beéerer x"; // 9 bytes, char boundary at 7? 'é' is 2 bytes — boundary check must gate
292        assert_eq!(TokenExtractor::parse_bearer(evil), None);
293        let evil2 = "Bearération token"; // 7-byte prefix matches "Bearer " in ASCII bytes? no — 'é' at byte 4
294        assert_eq!(TokenExtractor::parse_bearer(evil2), None);
295        // Boundary at 7 with ASCII prefix: still works.
296        assert_eq!(
297            TokenExtractor::parse_bearer("Bearer café"),
298            Some("café".to_string())
299        );
300    }
301
302    #[test]
303    fn parse_bearer_ok() {
304        assert_eq!(
305            TokenExtractor::parse_bearer("Bearer abc123"),
306            Some("abc123".to_string())
307        );
308        assert_eq!(
309            TokenExtractor::parse_bearer("bearer abc123"),
310            Some("abc123".to_string())
311        );
312        assert_eq!(
313            TokenExtractor::parse_bearer("BEARER abc123"),
314            Some("abc123".to_string())
315        );
316        assert_eq!(
317            TokenExtractor::parse_bearer("Bearer   spaced  "),
318            Some("spaced".to_string())
319        );
320    }
321
322    #[test]
323    fn parse_bearer_fail() {
324        assert_eq!(TokenExtractor::parse_bearer("Basic abc"), None);
325        assert_eq!(TokenExtractor::parse_bearer("Bearer "), None);
326        assert_eq!(TokenExtractor::parse_bearer("Bearer"), None);
327        assert_eq!(TokenExtractor::parse_bearer(""), None);
328    }
329
330    #[test]
331    fn query_param_extraction() {
332        assert_eq!(
333            TokenExtractor::extract_query_param("token=abc123&foo=bar", "token"),
334            Some("abc123".to_string())
335        );
336        assert_eq!(
337            TokenExtractor::extract_query_param("?access_token=xyz", "access_token"),
338            Some("xyz".to_string())
339        );
340        assert_eq!(
341            TokenExtractor::extract_query_param("foo=1&token=second", "token"),
342            Some("second".to_string())
343        );
344        assert_eq!(TokenExtractor::extract_query_param("", "token"), None);
345        assert_eq!(
346            TokenExtractor::extract_query_param("token=&foo=bar", "token"),
347            None
348        );
349    }
350
351    #[test]
352    fn query_url_decode() {
353        assert_eq!(
354            TokenExtractor::extract_query_param("token=hello%20world", "token"),
355            Some("hello world".to_string())
356        );
357        assert_eq!(
358            TokenExtractor::extract_query_param("token=a%2Bb", "token"),
359            Some("a+b".to_string())
360        );
361    }
362
363    #[test]
364    fn cookie_extraction() {
365        assert_eq!(
366            TokenExtractor::parse_cookie("session=abc; token=xyz; foo=bar", "token"),
367            Some("xyz".to_string())
368        );
369        assert_eq!(
370            TokenExtractor::parse_cookie("token=hello%20world", "token"),
371            Some("hello world".to_string())
372        );
373        assert_eq!(TokenExtractor::parse_cookie("a=b; c=d", "token"), None);
374    }
375
376    #[test]
377    fn extractor_order_header_first() {
378        let ex = TokenExtractor::default();
379        let token = ex.extract_from_parts(Some("Bearer header_tok"), None, "token=query_tok");
380        assert_eq!(token, Some("header_tok".to_string()));
381    }
382
383    #[test]
384    fn extractor_fallback_to_query() {
385        let ex = TokenExtractor::default();
386        let token = ex.extract_from_parts(None, None, "token=query_tok");
387        assert_eq!(token, Some("query_tok".to_string()));
388    }
389
390    #[test]
391    fn extractor_fallback_access_token() {
392        let ex = TokenExtractor::default();
393        let token = ex.extract_from_parts(None, None, "access_token=at");
394        assert_eq!(token, Some("at".to_string()));
395    }
396
397    #[test]
398    fn extractor_cookie_source() {
399        let ex = TokenExtractor::new(vec![TokenSourceKind::Cookie("session".to_string())]);
400        let token = ex.extract_from_parts(None, Some("session=mycookie"), "");
401        assert_eq!(token, Some("mycookie".to_string()));
402    }
403
404    #[test]
405    fn extractor_none_when_missing() {
406        let ex = TokenExtractor::default();
407        assert_eq!(ex.extract_from_parts(None, None, ""), None);
408    }
409
410    #[cfg(feature = "axum")]
411    mod axum_tests {
412        use super::*;
413        use http::{HeaderMap, Request};
414
415        fn parts_with(headers: HeaderMap, uri: &str) -> http::request::Parts {
416            let req = Request::builder().uri(uri).body(()).unwrap();
417            let (mut parts, _) = req.into_parts();
418            parts.headers = headers;
419            parts
420        }
421
422        #[test]
423        fn axum_bearer_extraction() {
424            let mut headers = HeaderMap::new();
425            headers.insert(
426                http::header::AUTHORIZATION,
427                "Bearer axum_tok".parse().unwrap(),
428            );
429            let parts = parts_with(headers, "/ws?token=ignored");
430            let ex = TokenExtractor::default();
431            assert_eq!(
432                ex.extract_token(&parts, "token=ignored"),
433                Some("axum_tok".to_string())
434            );
435        }
436
437        #[test]
438        fn axum_query_fallback() {
439            let parts = parts_with(HeaderMap::new(), "/ws?token=qtoken");
440            let ex = TokenExtractor::default();
441            assert_eq!(
442                ex.extract_token(&parts, "token=qtoken"),
443                Some("qtoken".to_string())
444            );
445        }
446
447        #[test]
448        fn axum_cookie() {
449            let mut headers = HeaderMap::new();
450            headers.insert(http::header::COOKIE, "tok=cookie_val".parse().unwrap());
451            let parts = parts_with(headers, "/ws");
452            let ex = TokenExtractor::new(vec![TokenSourceKind::Cookie("tok".to_string())]);
453            assert_eq!(ex.extract_token(&parts, ""), Some("cookie_val".to_string()));
454        }
455    }
456}