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[..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 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 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#[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 let evil = "Beéerer x"; assert_eq!(TokenExtractor::parse_bearer(evil), None);
290 let evil2 = "Bearération token"; assert_eq!(TokenExtractor::parse_bearer(evil2), None);
292 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}