1use thiserror::Error;
8
9#[cfg(feature = "axum")]
10use http::request::Parts;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum TokenSourceKind {
15 AuthorizationHeader,
17 QueryParam(String),
19 Cookie(String),
21}
22
23#[derive(Debug, Error, Clone, PartialEq, Eq)]
25pub enum WsAuthError {
26 #[error("authentication token missing")]
28 Missing,
29
30 #[error("authentication token invalid")]
32 Invalid,
33
34 #[error("authentication token expired")]
36 Expired,
37}
38
39#[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 pub fn new(sources: Vec<TokenSourceKind>) -> Self {
60 Self { sources }
61 }
62
63 pub fn bearer_only() -> Self {
65 Self {
66 sources: vec![TokenSourceKind::AuthorizationHeader],
67 }
68 }
69
70 pub fn with_source(mut self, src: TokenSourceKind) -> Self {
72 self.sources.push(src);
73 self
74 }
75
76 pub fn sources(&self) -> &[TokenSourceKind] {
78 &self.sources
79 }
80
81 #[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 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 #[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 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 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 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#[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 let evil = "Beéerer x"; assert_eq!(TokenExtractor::parse_bearer(evil), None);
293 let evil2 = "Bearération token"; assert_eq!(TokenExtractor::parse_bearer(evil2), None);
295 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}