1use std::collections::HashMap;
27use std::fmt;
28use std::sync::Arc;
29
30#[derive(Debug, Clone)]
32pub enum AuthError {
33 TokenMissing,
35 InvalidFormat(String),
37 TokenExpired,
39 InvalidSignature,
41 ValidationFailed(String),
43 InsufficientPermissions(String),
45}
46
47impl fmt::Display for AuthError {
48 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49 match self {
50 Self::TokenMissing => write!(f, "Authentication token missing"),
51 Self::InvalidFormat(msg) => write!(f, "Invalid token format: {}", msg),
52 Self::TokenExpired => write!(f, "Token has expired"),
53 Self::InvalidSignature => write!(f, "Invalid token signature"),
54 Self::ValidationFailed(msg) => write!(f, "Token validation failed: {}", msg),
55 Self::InsufficientPermissions(msg) => write!(f, "Insufficient permissions: {}", msg),
56 }
57 }
58}
59
60impl std::error::Error for AuthError {}
61
62impl AuthError {
63 pub fn validation_failed(msg: impl Into<String>) -> Self {
65 Self::ValidationFailed(msg.into())
66 }
67
68 pub fn invalid_format(msg: impl Into<String>) -> Self {
70 Self::InvalidFormat(msg.into())
71 }
72
73 pub fn insufficient_permissions(msg: impl Into<String>) -> Self {
75 Self::InsufficientPermissions(msg.into())
76 }
77}
78
79#[derive(Debug, Clone)]
83pub struct Claims {
84 pub sub: String,
86 pub extra: HashMap<String, String>,
88}
89
90impl Claims {
91 pub fn new(sub: impl Into<String>) -> Self {
93 Self {
94 sub: sub.into(),
95 extra: HashMap::new(),
96 }
97 }
98
99 pub fn with_extra(sub: impl Into<String>, extra: HashMap<String, String>) -> Self {
101 Self {
102 sub: sub.into(),
103 extra,
104 }
105 }
106
107 pub fn subject(&self) -> &str {
109 &self.sub
110 }
111
112 pub fn get(&self, key: &str) -> Option<&str> {
114 self.extra.get(key).map(|s| s.as_str())
115 }
116
117 pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) {
119 self.extra.insert(key.into(), value.into());
120 }
121}
122
123#[derive(Debug, Clone)]
125pub enum TokenExtractor {
126 Header(String),
128 Query(String),
130 Protocol,
132}
133
134impl Default for TokenExtractor {
135 fn default() -> Self {
136 Self::Header("Authorization".to_string())
137 }
138}
139
140impl TokenExtractor {
141 pub fn header(name: impl Into<String>) -> Self {
143 Self::Header(name.into())
144 }
145
146 pub fn query(name: impl Into<String>) -> Self {
148 Self::Query(name.into())
149 }
150
151 pub fn protocol() -> Self {
153 Self::Protocol
154 }
155
156 pub fn extract<B>(&self, req: &http::Request<B>) -> Option<String> {
158 match self {
159 TokenExtractor::Header(name) => {
160 req.headers()
161 .get(name)
162 .and_then(|v| v.to_str().ok())
163 .map(|s| {
164 if let Some(token) = s.strip_prefix("Bearer ") {
166 token.to_string()
167 } else {
168 s.to_string()
169 }
170 })
171 }
172 TokenExtractor::Query(name) => req.uri().query().and_then(|query| {
173 url::form_urlencoded::parse(query.as_bytes())
174 .find(|(key, _)| key == name)
175 .map(|(_, value)| value.into_owned())
176 }),
177 TokenExtractor::Protocol => req
178 .headers()
179 .get("Sec-WebSocket-Protocol")
180 .and_then(|v| v.to_str().ok())
181 .map(|s| s.to_string()),
182 }
183 }
184}
185
186#[async_trait::async_trait]
190pub trait TokenValidator: Send + Sync {
191 async fn validate(&self, token: &str) -> Result<Claims, AuthError>;
193}
194
195#[derive(Clone)]
197pub struct WsAuthConfig {
198 pub extractor: TokenExtractor,
200 pub validator: Arc<dyn TokenValidator>,
202 pub required: bool,
204}
205
206impl WsAuthConfig {
207 pub fn new<V: TokenValidator + 'static>(validator: V) -> Self {
209 Self {
210 extractor: TokenExtractor::default(),
211 validator: Arc::new(validator),
212 required: true,
213 }
214 }
215
216 pub fn extractor(mut self, extractor: TokenExtractor) -> Self {
218 self.extractor = extractor;
219 self
220 }
221
222 pub fn required(mut self, required: bool) -> Self {
224 self.required = required;
225 self
226 }
227
228 pub async fn authenticate<B>(
230 &self,
231 req: &http::Request<B>,
232 ) -> Result<Option<Claims>, AuthError> {
233 match self.extractor.extract(req) {
234 Some(token) => {
235 let claims = self.validator.validate(&token).await?;
236 Ok(Some(claims))
237 }
238 None if self.required => Err(AuthError::TokenMissing),
239 None => Ok(None),
240 }
241 }
242}
243
244impl std::fmt::Debug for WsAuthConfig {
245 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246 f.debug_struct("WsAuthConfig")
247 .field("extractor", &self.extractor)
248 .field("required", &self.required)
249 .finish()
250 }
251}
252
253pub struct AcceptAllValidator;
257
258#[async_trait::async_trait]
259impl TokenValidator for AcceptAllValidator {
260 async fn validate(&self, token: &str) -> Result<Claims, AuthError> {
261 if token.is_empty() {
262 return Err(AuthError::invalid_format("Token cannot be empty"));
263 }
264 Ok(Claims::new(token))
265 }
266}
267
268pub struct RejectAllValidator;
272
273#[async_trait::async_trait]
274impl TokenValidator for RejectAllValidator {
275 async fn validate(&self, _token: &str) -> Result<Claims, AuthError> {
276 Err(AuthError::validation_failed("All tokens rejected"))
277 }
278}
279
280pub struct StaticTokenValidator {
282 tokens: HashMap<String, Claims>,
283}
284
285impl StaticTokenValidator {
286 pub fn new() -> Self {
288 Self {
289 tokens: HashMap::new(),
290 }
291 }
292
293 pub fn add_token(mut self, token: impl Into<String>, claims: Claims) -> Self {
295 self.tokens.insert(token.into(), claims);
296 self
297 }
298}
299
300impl Default for StaticTokenValidator {
301 fn default() -> Self {
302 Self::new()
303 }
304}
305
306#[async_trait::async_trait]
307impl TokenValidator for StaticTokenValidator {
308 async fn validate(&self, token: &str) -> Result<Claims, AuthError> {
309 self.tokens
310 .get(token)
311 .cloned()
312 .ok_or_else(|| AuthError::validation_failed("Invalid token"))
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319 use http::Request;
320
321 #[test]
322 fn test_token_extractor_header() {
323 let extractor = TokenExtractor::header("Authorization");
324
325 let req = Request::builder()
326 .header("Authorization", "Bearer test-token")
327 .body(())
328 .unwrap();
329
330 assert_eq!(extractor.extract(&req), Some("test-token".to_string()));
331 }
332
333 #[test]
334 fn test_token_extractor_header_no_bearer() {
335 let extractor = TokenExtractor::header("X-API-Key");
336
337 let req = Request::builder()
338 .header("X-API-Key", "my-api-key")
339 .body(())
340 .unwrap();
341
342 assert_eq!(extractor.extract(&req), Some("my-api-key".to_string()));
343 }
344
345 #[test]
346 fn test_token_extractor_query() {
347 let extractor = TokenExtractor::query("token");
348
349 let req = Request::builder()
350 .uri("ws://localhost/ws?token=query-token&other=value")
351 .body(())
352 .unwrap();
353
354 assert_eq!(extractor.extract(&req), Some("query-token".to_string()));
355 }
356
357 #[test]
358 fn test_token_extractor_protocol() {
359 let extractor = TokenExtractor::protocol();
360
361 let req = Request::builder()
362 .header("Sec-WebSocket-Protocol", "my-protocol-token")
363 .body(())
364 .unwrap();
365
366 assert_eq!(
367 extractor.extract(&req),
368 Some("my-protocol-token".to_string())
369 );
370 }
371
372 #[test]
373 fn test_token_extractor_missing() {
374 let extractor = TokenExtractor::header("Authorization");
375
376 let req = Request::builder().body(()).unwrap();
377
378 assert_eq!(extractor.extract(&req), None);
379 }
380
381 #[tokio::test]
382 async fn test_accept_all_validator() {
383 let validator = AcceptAllValidator;
384
385 let result = validator.validate("any-token").await;
386 assert!(result.is_ok());
387 assert_eq!(result.unwrap().subject(), "any-token");
388 }
389
390 #[tokio::test]
391 async fn test_accept_all_validator_empty() {
392 let validator = AcceptAllValidator;
393
394 let result = validator.validate("").await;
395 assert!(result.is_err());
396 }
397
398 #[tokio::test]
399 async fn test_reject_all_validator() {
400 let validator = RejectAllValidator;
401
402 let result = validator.validate("any-token").await;
403 assert!(result.is_err());
404 }
405
406 #[tokio::test]
407 async fn test_static_token_validator() {
408 let validator =
409 StaticTokenValidator::new().add_token("valid-token", Claims::new("user-123"));
410
411 let result = validator.validate("valid-token").await;
412 assert!(result.is_ok());
413 assert_eq!(result.unwrap().subject(), "user-123");
414
415 let result = validator.validate("invalid-token").await;
416 assert!(result.is_err());
417 }
418
419 #[tokio::test]
420 async fn test_ws_auth_config_required() {
421 let config = WsAuthConfig::new(AcceptAllValidator)
422 .extractor(TokenExtractor::header("Authorization"))
423 .required(true);
424
425 let req = Request::builder().body(()).unwrap();
426
427 let result = config.authenticate(&req).await;
428 assert!(matches!(result, Err(AuthError::TokenMissing)));
429 }
430
431 #[tokio::test]
432 async fn test_ws_auth_config_optional() {
433 let config = WsAuthConfig::new(AcceptAllValidator)
434 .extractor(TokenExtractor::header("Authorization"))
435 .required(false);
436
437 let req = Request::builder().body(()).unwrap();
438
439 let result = config.authenticate(&req).await;
440 assert!(result.is_ok());
441 assert!(result.unwrap().is_none());
442 }
443
444 #[tokio::test]
445 async fn test_ws_auth_config_with_token() {
446 let config = WsAuthConfig::new(AcceptAllValidator)
447 .extractor(TokenExtractor::header("Authorization"));
448
449 let req = Request::builder()
450 .header("Authorization", "Bearer my-token")
451 .body(())
452 .unwrap();
453
454 let result = config.authenticate(&req).await;
455 assert!(result.is_ok());
456 let claims = result.unwrap().unwrap();
457 assert_eq!(claims.subject(), "my-token");
458 }
459
460 #[test]
461 fn test_claims_extra() {
462 let mut claims = Claims::new("user-123");
463 claims.insert("role", "admin");
464 claims.insert("tenant", "acme");
465
466 assert_eq!(claims.subject(), "user-123");
467 assert_eq!(claims.get("role"), Some("admin"));
468 assert_eq!(claims.get("tenant"), Some("acme"));
469 assert_eq!(claims.get("missing"), None);
470 }
471
472 #[test]
473 fn test_auth_error_display() {
474 let err = AuthError::TokenMissing;
475 assert_eq!(err.to_string(), "Authentication token missing");
476
477 let err = AuthError::validation_failed("custom error");
478 assert_eq!(err.to_string(), "Token validation failed: custom error");
479 }
480
481 #[test]
482 fn test_token_extractor_default() {
483 let extractor = TokenExtractor::default();
484 match extractor {
485 TokenExtractor::Header(name) => assert_eq!(name, "Authorization"),
486 _ => panic!("Expected Header extractor"),
487 }
488 }
489}
490
491#[cfg(test)]
496mod property_tests {
497 use super::*;
498 use proptest::prelude::*;
499
500 fn token_strategy() -> impl Strategy<Value = String> {
502 prop::string::string_regex("[a-zA-Z0-9._-]{1,100}").unwrap()
503 }
504
505 fn header_name_strategy() -> impl Strategy<Value = String> {
507 prop::string::string_regex("[A-Za-z][A-Za-z0-9-]{0,30}").unwrap()
508 }
509
510 fn query_param_strategy() -> impl Strategy<Value = String> {
512 prop::string::string_regex("[a-z][a-z0-9_]{0,20}").unwrap()
513 }
514
515 fn extractor_strategy() -> impl Strategy<Value = TokenExtractor> {
517 prop_oneof![
518 header_name_strategy().prop_map(TokenExtractor::Header),
519 query_param_strategy().prop_map(TokenExtractor::Query),
520 Just(TokenExtractor::Protocol),
521 ]
522 }
523
524 proptest! {
525 #[test]
531 fn prop_auth_required_rejects_missing_token(
532 extractor in extractor_strategy()
533 ) {
534 let rt = tokio::runtime::Runtime::new().unwrap();
535 rt.block_on(async {
536 let config = WsAuthConfig::new(AcceptAllValidator)
537 .extractor(extractor)
538 .required(true);
539
540 let req = http::Request::builder()
542 .uri("ws://localhost/ws")
543 .body(())
544 .unwrap();
545
546 let result = config.authenticate(&req).await;
547 prop_assert!(matches!(result, Err(AuthError::TokenMissing)));
548 Ok(())
549 })?;
550 }
551
552 #[test]
558 fn prop_auth_accepts_valid_token_in_header(
559 token in token_strategy(),
560 header_name in header_name_strategy()
561 ) {
562 let rt = tokio::runtime::Runtime::new().unwrap();
563 rt.block_on(async {
564 let config = WsAuthConfig::new(AcceptAllValidator)
565 .extractor(TokenExtractor::Header(header_name.clone()))
566 .required(true);
567
568 let req = http::Request::builder()
569 .uri("ws://localhost/ws")
570 .header(&header_name, format!("Bearer {}", token))
571 .body(())
572 .unwrap();
573
574 let result = config.authenticate(&req).await;
575 prop_assert!(result.is_ok());
576 let claims = result.unwrap();
577 prop_assert!(claims.is_some());
578 let claims = claims.unwrap();
579 prop_assert_eq!(claims.subject(), &token);
580 Ok(())
581 })?;
582 }
583
584 #[test]
590 fn prop_auth_accepts_valid_token_in_query(
591 token in token_strategy(),
592 param_name in query_param_strategy()
593 ) {
594 let rt = tokio::runtime::Runtime::new().unwrap();
595 rt.block_on(async {
596 let config = WsAuthConfig::new(AcceptAllValidator)
597 .extractor(TokenExtractor::Query(param_name.clone()))
598 .required(true);
599
600 let uri = format!("ws://localhost/ws?{}={}", param_name, token);
601 let req = http::Request::builder()
602 .uri(&uri)
603 .body(())
604 .unwrap();
605
606 let result = config.authenticate(&req).await;
607 prop_assert!(result.is_ok());
608 let claims = result.unwrap();
609 prop_assert!(claims.is_some());
610 let claims = claims.unwrap();
611 prop_assert_eq!(claims.subject(), &token);
612 Ok(())
613 })?;
614 }
615
616 #[test]
622 fn prop_auth_rejects_invalid_token(
623 token in token_strategy()
624 ) {
625 let rt = tokio::runtime::Runtime::new().unwrap();
626 rt.block_on(async {
627 let config = WsAuthConfig::new(RejectAllValidator)
628 .extractor(TokenExtractor::Header("Authorization".to_string()))
629 .required(true);
630
631 let req = http::Request::builder()
632 .uri("ws://localhost/ws")
633 .header("Authorization", format!("Bearer {}", token))
634 .body(())
635 .unwrap();
636
637 let result = config.authenticate(&req).await;
638 prop_assert!(result.is_err());
639 prop_assert!(matches!(result, Err(AuthError::ValidationFailed(_))));
640 Ok(())
641 })?;
642 }
643
644 #[test]
650 fn prop_optional_auth_allows_missing_token(
651 extractor in extractor_strategy()
652 ) {
653 let rt = tokio::runtime::Runtime::new().unwrap();
654 rt.block_on(async {
655 let config = WsAuthConfig::new(AcceptAllValidator)
656 .extractor(extractor)
657 .required(false);
658
659 let req = http::Request::builder()
660 .uri("ws://localhost/ws")
661 .body(())
662 .unwrap();
663
664 let result = config.authenticate(&req).await;
665 prop_assert!(result.is_ok());
666 prop_assert!(result.unwrap().is_none());
667 Ok(())
668 })?;
669 }
670
671 #[test]
677 fn prop_static_validator_only_accepts_known_tokens(
678 valid_token in token_strategy(),
679 test_token in token_strategy(),
680 user_id in "[a-z]{3,10}"
681 ) {
682 let rt = tokio::runtime::Runtime::new().unwrap();
683 rt.block_on(async {
684 let validator = StaticTokenValidator::new()
685 .add_token(valid_token.clone(), Claims::new(user_id.clone()));
686
687 let result = validator.validate(&test_token).await;
688
689 if test_token == valid_token {
690 prop_assert!(result.is_ok());
691 let claims = result.unwrap();
692 prop_assert_eq!(claims.subject(), &user_id);
693 } else {
694 prop_assert!(result.is_err());
695 }
696 Ok(())
697 })?;
698 }
699 }
700}