Skip to main content

rustapi_ws/
auth.rs

1//! WebSocket authentication support
2//!
3//! This module provides authentication infrastructure for WebSocket connections,
4//! allowing token validation before the WebSocket upgrade completes.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use rustapi_ws::auth::{WsAuthConfig, TokenExtractor, TokenValidator, Claims};
10//! use async_trait::async_trait;
11//!
12//! struct MyTokenValidator;
13//!
14//! #[async_trait]
15//! impl TokenValidator for MyTokenValidator {
16//!     async fn validate(&self, token: &str) -> Result<Claims, AuthError> {
17//!         // Validate JWT or other token format
18//!         Ok(Claims::new("user_123"))
19//!     }
20//! }
21//!
22//! let config = WsAuthConfig::new(Box::new(MyTokenValidator))
23//!     .extractor(TokenExtractor::Header("Authorization".to_string()));
24//! ```
25
26use std::collections::HashMap;
27use std::fmt;
28use std::sync::Arc;
29
30/// Error type for WebSocket authentication
31#[derive(Debug, Clone)]
32pub enum AuthError {
33    /// Token is missing from the request
34    TokenMissing,
35    /// Token format is invalid
36    InvalidFormat(String),
37    /// Token has expired
38    TokenExpired,
39    /// Token signature is invalid
40    InvalidSignature,
41    /// Token validation failed
42    ValidationFailed(String),
43    /// Insufficient permissions
44    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    /// Create a validation failed error
64    pub fn validation_failed(msg: impl Into<String>) -> Self {
65        Self::ValidationFailed(msg.into())
66    }
67
68    /// Create an invalid format error
69    pub fn invalid_format(msg: impl Into<String>) -> Self {
70        Self::InvalidFormat(msg.into())
71    }
72
73    /// Create an insufficient permissions error
74    pub fn insufficient_permissions(msg: impl Into<String>) -> Self {
75        Self::InsufficientPermissions(msg.into())
76    }
77}
78
79/// Claims extracted from a validated token
80///
81/// Contains the user identity and any additional claims from the token.
82#[derive(Debug, Clone)]
83pub struct Claims {
84    /// Subject (user ID)
85    pub sub: String,
86    /// Additional claims as key-value pairs
87    pub extra: HashMap<String, String>,
88}
89
90impl Claims {
91    /// Create new claims with just a subject
92    pub fn new(sub: impl Into<String>) -> Self {
93        Self {
94            sub: sub.into(),
95            extra: HashMap::new(),
96        }
97    }
98
99    /// Create claims with subject and extra data
100    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    /// Get the subject (user ID)
108    pub fn subject(&self) -> &str {
109        &self.sub
110    }
111
112    /// Get an extra claim by key
113    pub fn get(&self, key: &str) -> Option<&str> {
114        self.extra.get(key).map(|s| s.as_str())
115    }
116
117    /// Add an extra claim
118    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/// Specifies where to extract the authentication token from
124#[derive(Debug, Clone)]
125pub enum TokenExtractor {
126    /// Extract from a header (e.g., "Authorization")
127    Header(String),
128    /// Extract from a query parameter (e.g., "token")
129    Query(String),
130    /// Extract from the Sec-WebSocket-Protocol header
131    Protocol,
132}
133
134impl Default for TokenExtractor {
135    fn default() -> Self {
136        Self::Header("Authorization".to_string())
137    }
138}
139
140impl TokenExtractor {
141    /// Create a header extractor
142    pub fn header(name: impl Into<String>) -> Self {
143        Self::Header(name.into())
144    }
145
146    /// Create a query parameter extractor
147    pub fn query(name: impl Into<String>) -> Self {
148        Self::Query(name.into())
149    }
150
151    /// Create a protocol extractor
152    pub fn protocol() -> Self {
153        Self::Protocol
154    }
155
156    /// Extract the token from an HTTP request
157    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                        // Strip "Bearer " prefix if present
165                        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/// Trait for validating authentication tokens
187///
188/// Implement this trait to provide custom token validation logic.
189#[async_trait::async_trait]
190pub trait TokenValidator: Send + Sync {
191    /// Validate a token and return the claims if valid
192    async fn validate(&self, token: &str) -> Result<Claims, AuthError>;
193}
194
195/// Configuration for WebSocket authentication
196#[derive(Clone)]
197pub struct WsAuthConfig {
198    /// Token extractor configuration
199    pub extractor: TokenExtractor,
200    /// Token validator
201    pub validator: Arc<dyn TokenValidator>,
202    /// Whether authentication is required (if false, missing tokens are allowed)
203    pub required: bool,
204}
205
206impl WsAuthConfig {
207    /// Create a new authentication configuration with a validator
208    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    /// Set the token extractor
217    pub fn extractor(mut self, extractor: TokenExtractor) -> Self {
218        self.extractor = extractor;
219        self
220    }
221
222    /// Set whether authentication is required
223    pub fn required(mut self, required: bool) -> Self {
224        self.required = required;
225        self
226    }
227
228    /// Extract and validate a token from a request
229    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
253/// A simple token validator that accepts any non-empty token
254///
255/// This is useful for testing or when token validation is handled elsewhere.
256pub 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
268/// A token validator that rejects all tokens
269///
270/// This is useful for testing authentication failure scenarios.
271pub 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
280/// A token validator that validates against a static list of valid tokens
281pub struct StaticTokenValidator {
282    tokens: HashMap<String, Claims>,
283}
284
285impl StaticTokenValidator {
286    /// Create a new static token validator
287    pub fn new() -> Self {
288        Self {
289            tokens: HashMap::new(),
290        }
291    }
292
293    /// Add a valid token with associated claims
294    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/// Property-based tests for WebSocket authentication
492///
493/// **Feature: v1-features-roadmap, Property 10: WebSocket authentication enforcement**
494/// **Validates: Requirements 4.1, 4.3**
495#[cfg(test)]
496mod property_tests {
497    use super::*;
498    use proptest::prelude::*;
499
500    /// Strategy for generating random tokens
501    fn token_strategy() -> impl Strategy<Value = String> {
502        prop::string::string_regex("[a-zA-Z0-9._-]{1,100}").unwrap()
503    }
504
505    /// Strategy for generating random header names
506    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    /// Strategy for generating random query parameter names
511    fn query_param_strategy() -> impl Strategy<Value = String> {
512        prop::string::string_regex("[a-z][a-z0-9_]{0,20}").unwrap()
513    }
514
515    /// Strategy for generating token extractors
516    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        /// **Feature: v1-features-roadmap, Property 10: WebSocket authentication enforcement**
526        /// **Validates: Requirements 4.1, 4.3**
527        ///
528        /// For any WebSocket connection attempt with required authentication,
529        /// if no token is provided, authentication SHALL fail with TokenMissing error.
530        #[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                // Request without any token
541                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        /// **Feature: v1-features-roadmap, Property 10: WebSocket authentication enforcement**
553        /// **Validates: Requirements 4.1, 4.3**
554        ///
555        /// For any WebSocket connection attempt with a valid token,
556        /// authentication SHALL succeed and return claims.
557        #[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        /// **Feature: v1-features-roadmap, Property 10: WebSocket authentication enforcement**
585        /// **Validates: Requirements 4.1, 4.3**
586        ///
587        /// For any WebSocket connection attempt with a valid token in query,
588        /// authentication SHALL succeed and return claims.
589        #[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        /// **Feature: v1-features-roadmap, Property 10: WebSocket authentication enforcement**
617        /// **Validates: Requirements 4.1, 4.3**
618        ///
619        /// For any WebSocket connection attempt with an invalid token,
620        /// authentication SHALL fail with validation error.
621        #[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        /// **Feature: v1-features-roadmap, Property 10: WebSocket authentication enforcement**
645        /// **Validates: Requirements 4.1, 4.3**
646        ///
647        /// For any WebSocket connection with optional auth and no token,
648        /// authentication SHALL succeed with None claims.
649        #[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        /// **Feature: v1-features-roadmap, Property 10: WebSocket authentication enforcement**
672        /// **Validates: Requirements 4.1, 4.3**
673        ///
674        /// For any static token validator with known valid tokens,
675        /// only those exact tokens SHALL be accepted.
676        #[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}