Skip to main content

hyperstack_server/websocket/
auth.rs

1use std::any::Any;
2use std::collections::{HashMap, HashSet};
3use std::net::SocketAddr;
4use std::sync::Arc;
5use std::time::Duration;
6
7use async_trait::async_trait;
8use tokio_tungstenite::tungstenite::http::Request;
9
10// Re-export AuthContext from hyperstack-auth for convenience
11pub use hyperstack_auth::AuthContext;
12// Re-export AuthErrorCode for convenience
13pub use hyperstack_auth::AuthErrorCode;
14// Re-export RetryPolicy for convenience
15pub use hyperstack_auth::RetryPolicy;
16// Re-export audit types
17pub use hyperstack_auth::{
18    auth_failure_event, auth_success_event, rate_limit_event, AuditEvent, AuditSeverity,
19    ChannelAuditLogger, NoOpAuditLogger, SecurityAuditEvent, SecurityAuditLogger,
20};
21// Re-export metrics types
22pub use hyperstack_auth::{AuthMetrics, AuthMetricsCollector, AuthMetricsSnapshot};
23// Re-export multi-key verifier types
24pub use hyperstack_auth::{MultiKeyVerifier, MultiKeyVerifierBuilder, RotationKey};
25
26#[derive(Debug, Clone)]
27pub struct ConnectionAuthRequest {
28    pub remote_addr: SocketAddr,
29    pub path: String,
30    pub query: Option<String>,
31    pub headers: HashMap<String, String>,
32    /// Origin header from the request (for browser origin validation)
33    pub origin: Option<String>,
34}
35
36impl ConnectionAuthRequest {
37    pub fn from_http_request<B>(remote_addr: SocketAddr, request: &Request<B>) -> Self {
38        let mut headers = HashMap::new();
39        for (name, value) in request.headers() {
40            if let Ok(value_str) = value.to_str() {
41                headers.insert(name.as_str().to_ascii_lowercase(), value_str.to_string());
42            }
43        }
44
45        let origin = headers.get("origin").cloned();
46
47        Self {
48            remote_addr,
49            path: request.uri().path().to_string(),
50            query: request.uri().query().map(|q| q.to_string()),
51            headers,
52            origin,
53        }
54    }
55
56    pub fn header(&self, name: &str) -> Option<&str> {
57        self.headers
58            .get(&name.to_ascii_lowercase())
59            .map(String::as_str)
60    }
61
62    pub fn bearer_token(&self) -> Option<&str> {
63        let value = self.header("authorization")?;
64        let (scheme, token) = value.split_once(' ')?;
65        if scheme.eq_ignore_ascii_case("bearer") {
66            Some(token)
67        } else {
68            None
69        }
70    }
71
72    pub fn query_param(&self, key: &str) -> Option<&str> {
73        let query = self.query.as_deref()?;
74        query
75            .split('&')
76            .filter_map(|pair| pair.split_once('='))
77            .find_map(|(k, v)| if k == key { Some(v) } else { None })
78    }
79}
80
81/// Structured error details for machine-readable error handling
82#[derive(Debug, Clone, Default)]
83pub struct AuthErrorDetails {
84    /// The specific field or parameter that caused the error (if applicable)
85    pub field: Option<String>,
86    /// Additional context about the error
87    pub context: Option<String>,
88    /// Suggested action for the client to resolve the error
89    pub suggested_action: Option<String>,
90    /// Related documentation URL
91    pub docs_url: Option<String>,
92}
93
94/// Enhanced authentication denial with structured error information
95#[derive(Debug, Clone)]
96pub struct AuthDeny {
97    pub reason: String,
98    pub code: AuthErrorCode,
99    /// Structured error details for machine processing
100    pub details: AuthErrorDetails,
101    /// Retry policy hint
102    pub retry_policy: RetryPolicy,
103    /// HTTP status code equivalent for the error
104    pub http_status: u16,
105    /// When the error condition will reset (if applicable)
106    pub reset_at: Option<std::time::SystemTime>,
107}
108
109impl AuthDeny {
110    /// Create a new AuthDeny with the specified error code and reason
111    pub fn new(code: AuthErrorCode, reason: impl Into<String>) -> Self {
112        Self {
113            reason: reason.into(),
114            code,
115            details: AuthErrorDetails::default(),
116            retry_policy: code.default_retry_policy(),
117            http_status: code.http_status(),
118            reset_at: None,
119        }
120    }
121
122    /// Create an AuthDeny for missing token
123    pub fn token_missing() -> Self {
124        Self::new(
125            AuthErrorCode::TokenMissing,
126            "Missing session token (expected Authorization: Bearer <token> or query token)",
127        )
128        .with_suggested_action(
129            "Provide a valid session token in the Authorization header or as a query parameter",
130        )
131    }
132
133    /// Create an AuthDeny from a VerifyError
134    pub fn from_verify_error(err: hyperstack_auth::VerifyError) -> Self {
135        let code = AuthErrorCode::from(&err);
136        Self::new(code, format!("Token verification failed: {}", err))
137    }
138
139    /// Add structured error details
140    pub fn with_details(mut self, details: AuthErrorDetails) -> Self {
141        self.details = details;
142        self
143    }
144
145    /// Add a specific field that caused the error
146    pub fn with_field(mut self, field: impl Into<String>) -> Self {
147        self.details.field = Some(field.into());
148        self
149    }
150
151    /// Add context to the error
152    pub fn with_context(mut self, context: impl Into<String>) -> Self {
153        self.details.context = Some(context.into());
154        self
155    }
156
157    /// Add a suggested action for the client
158    pub fn with_suggested_action(mut self, action: impl Into<String>) -> Self {
159        self.details.suggested_action = Some(action.into());
160        self
161    }
162
163    /// Add documentation URL
164    pub fn with_docs_url(mut self, url: impl Into<String>) -> Self {
165        self.details.docs_url = Some(url.into());
166        self
167    }
168
169    /// Set a custom retry policy
170    pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
171        self.retry_policy = policy;
172        self
173    }
174
175    /// Set when the error condition will reset
176    pub fn with_reset_at(mut self, reset_at: std::time::SystemTime) -> Self {
177        self.reset_at = Some(reset_at);
178        self
179    }
180
181    /// Create an AuthDeny for rate limiting with retry information
182    pub fn rate_limited(retry_after: Duration, limit_type: &str) -> Self {
183        let reset_at = std::time::SystemTime::now() + retry_after;
184        Self::new(
185            AuthErrorCode::RateLimitExceeded,
186            format!(
187                "Rate limit exceeded for {}. Please retry after {:?}.",
188                limit_type, retry_after
189            ),
190        )
191        .with_retry_policy(RetryPolicy::RetryAfter(retry_after))
192        .with_reset_at(reset_at)
193        .with_suggested_action(format!(
194            "Wait {:?} before retrying the request",
195            retry_after
196        ))
197    }
198
199    /// Create an AuthDeny for connection limits
200    pub fn connection_limit_exceeded(limit_type: &str, current: usize, max: usize) -> Self {
201        Self::new(
202            AuthErrorCode::ConnectionLimitExceeded,
203            format!(
204                "Connection limit exceeded: {} has {} of {} allowed connections",
205                limit_type, current, max
206            ),
207        )
208        .with_suggested_action(
209            "Disconnect existing connections or wait for other connections to close",
210        )
211    }
212
213    /// Convert to a JSON-serializable error response
214    pub fn to_error_response(&self) -> ErrorResponse {
215        ErrorResponse {
216            error: self.code.as_str().to_string(),
217            message: self.reason.clone(),
218            code: self.code.to_string(),
219            retryable: matches!(
220                self.retry_policy,
221                RetryPolicy::RetryImmediately
222                    | RetryPolicy::RetryAfter(_)
223                    | RetryPolicy::RetryWithBackoff { .. }
224                    | RetryPolicy::RetryWithFreshToken
225            ),
226            retry_after: match self.retry_policy {
227                RetryPolicy::RetryAfter(d) => Some(d.as_secs()),
228                _ => None,
229            },
230            suggested_action: self.details.suggested_action.clone(),
231            docs_url: self.details.docs_url.clone(),
232        }
233    }
234}
235
236/// JSON-serializable error response for clients
237#[derive(Debug, Clone, serde::Serialize)]
238pub struct ErrorResponse {
239    pub error: String,
240    pub message: String,
241    pub code: String,
242    pub retryable: bool,
243    #[serde(skip_serializing_if = "Option::is_none")]
244    pub retry_after: Option<u64>,
245    #[serde(skip_serializing_if = "Option::is_none")]
246    pub suggested_action: Option<String>,
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub docs_url: Option<String>,
249}
250
251/// Authentication decision with optional auth context
252#[derive(Debug, Clone)]
253pub enum AuthDecision {
254    /// Connection is authorized with the given context
255    Allow(AuthContext),
256    /// Connection is denied
257    Deny(AuthDeny),
258}
259
260impl AuthDecision {
261    /// Check if the decision is Allow
262    pub fn is_allowed(&self) -> bool {
263        matches!(self, AuthDecision::Allow(_))
264    }
265
266    /// Get the auth context if allowed
267    pub fn auth_context(&self) -> Option<&AuthContext> {
268        match self {
269            AuthDecision::Allow(ctx) => Some(ctx),
270            AuthDecision::Deny(_) => None,
271        }
272    }
273}
274
275#[async_trait]
276pub trait WebSocketAuthPlugin: Send + Sync + Any {
277    async fn authorize(&self, request: &ConnectionAuthRequest) -> AuthDecision;
278
279    fn as_any(&self) -> &dyn Any;
280
281    /// Get the audit logger if configured
282    fn audit_logger(&self) -> Option<&dyn SecurityAuditLogger> {
283        None
284    }
285
286    /// Log a security audit event if audit logging is enabled
287    async fn log_audit(&self, event: SecurityAuditEvent) {
288        if let Some(logger) = self.audit_logger() {
289            logger.log(event).await;
290        }
291    }
292
293    /// Get auth metrics if configured
294    fn auth_metrics(&self) -> Option<&AuthMetrics> {
295        None
296    }
297}
298
299/// Development-only plugin that allows all connections
300///
301/// # Warning
302/// This should only be used for local development. Never use in production.
303pub struct AllowAllAuthPlugin;
304
305#[async_trait]
306impl WebSocketAuthPlugin for AllowAllAuthPlugin {
307    async fn authorize(&self, _request: &ConnectionAuthRequest) -> AuthDecision {
308        // Create a default auth context for development
309        let context = AuthContext {
310            subject: "anonymous".to_string(),
311            issuer: "allow-all".to_string(),
312            key_class: hyperstack_auth::KeyClass::Secret,
313            metering_key: "dev".to_string(),
314            deployment_id: None,
315            expires_at: u64::MAX, // Never expires
316            scope: "read write".to_string(),
317            limits: Default::default(),
318            plan: None,
319            origin: None,
320            client_ip: None,
321            jti: uuid::Uuid::new_v4().to_string(),
322        };
323        AuthDecision::Allow(context)
324    }
325
326    fn as_any(&self) -> &dyn Any {
327        self
328    }
329}
330
331#[derive(Debug, Clone)]
332pub struct StaticTokenAuthPlugin {
333    tokens: HashSet<String>,
334    query_param_name: String,
335}
336
337impl StaticTokenAuthPlugin {
338    pub fn new(tokens: impl IntoIterator<Item = String>) -> Self {
339        Self {
340            tokens: tokens.into_iter().collect(),
341            query_param_name: "token".to_string(),
342        }
343    }
344
345    pub fn with_query_param_name(mut self, query_param_name: impl Into<String>) -> Self {
346        self.query_param_name = query_param_name.into();
347        self
348    }
349
350    fn extract_token<'a>(&self, request: &'a ConnectionAuthRequest) -> Option<&'a str> {
351        request
352            .bearer_token()
353            .or_else(|| request.query_param(&self.query_param_name))
354    }
355}
356
357#[async_trait]
358impl WebSocketAuthPlugin for StaticTokenAuthPlugin {
359    async fn authorize(&self, request: &ConnectionAuthRequest) -> AuthDecision {
360        let token = match self.extract_token(request) {
361            Some(token) => token,
362            None => {
363                return AuthDecision::Deny(AuthDeny::token_missing());
364            }
365        };
366
367        if self.tokens.contains(token) {
368            // Create auth context for static token
369            let context = AuthContext {
370                subject: format!("static:{}", &token[..token.len().min(8)]),
371                issuer: "static-token".to_string(),
372                key_class: hyperstack_auth::KeyClass::Secret,
373                metering_key: token.to_string(),
374                deployment_id: None,
375                expires_at: u64::MAX, // Static tokens don't expire
376                scope: "read".to_string(),
377                limits: Default::default(),
378                plan: None,
379                origin: request.origin.clone(),
380                client_ip: None,
381                jti: uuid::Uuid::new_v4().to_string(),
382            };
383            AuthDecision::Allow(context)
384        } else {
385            AuthDecision::Deny(AuthDeny::new(
386                AuthErrorCode::InvalidStaticToken,
387                "Invalid auth token",
388            ))
389        }
390    }
391
392    fn as_any(&self) -> &dyn Any {
393        self
394    }
395}
396
397/// Signed session token authentication plugin
398///
399/// This plugin verifies JWT session tokens using Ed25519 signatures.
400/// Tokens are expected to be passed either:
401/// - In the Authorization header: `Authorization: Bearer <token>`
402/// - As a query parameter: `?hs_token=<token>`
403enum SignedSessionVerifier {
404    Static(hyperstack_auth::TokenVerifier),
405    CachedJwks(hyperstack_auth::AsyncVerifier),
406    MultiKey(hyperstack_auth::MultiKeyVerifier),
407}
408
409pub struct SignedSessionAuthPlugin {
410    verifier: SignedSessionVerifier,
411    query_param_name: String,
412    require_origin: bool,
413    audit_logger: Option<Arc<dyn SecurityAuditLogger>>,
414    metrics: Option<Arc<AuthMetrics>>,
415}
416
417impl SignedSessionAuthPlugin {
418    /// Create a new signed session auth plugin
419    pub fn new(verifier: hyperstack_auth::TokenVerifier) -> Self {
420        Self {
421            verifier: SignedSessionVerifier::Static(verifier),
422            query_param_name: "hs_token".to_string(),
423            require_origin: false,
424            audit_logger: None,
425            metrics: None,
426        }
427    }
428
429    /// Create a signed session auth plugin backed by an async verifier, such as JWKS.
430    pub fn new_with_async_verifier(verifier: hyperstack_auth::AsyncVerifier) -> Self {
431        Self {
432            verifier: SignedSessionVerifier::CachedJwks(verifier),
433            query_param_name: "hs_token".to_string(),
434            require_origin: false,
435            audit_logger: None,
436            metrics: None,
437        }
438    }
439
440    /// Create a signed session auth plugin backed by a multi-key verifier for key rotation.
441    pub fn new_with_multi_key_verifier(verifier: hyperstack_auth::MultiKeyVerifier) -> Self {
442        Self {
443            verifier: SignedSessionVerifier::MultiKey(verifier),
444            query_param_name: "hs_token".to_string(),
445            require_origin: false,
446            audit_logger: None,
447            metrics: None,
448        }
449    }
450
451    /// Set a custom query parameter name for the token
452    pub fn with_query_param_name(mut self, name: impl Into<String>) -> Self {
453        self.query_param_name = name.into();
454        self
455    }
456
457    /// Require origin validation (defense-in-depth for browser clients)
458    pub fn with_origin_validation(mut self) -> Self {
459        self.require_origin = true;
460        self
461    }
462
463    /// Set an audit logger for security events
464    pub fn with_audit_logger(mut self, logger: Arc<dyn SecurityAuditLogger>) -> Self {
465        self.audit_logger = Some(logger);
466        self
467    }
468
469    /// Set metrics collector for auth operations
470    pub fn with_metrics(mut self, metrics: Arc<AuthMetrics>) -> Self {
471        self.metrics = Some(metrics);
472        self
473    }
474
475    /// Get metrics snapshot if metrics are enabled
476    pub fn metrics_snapshot(&self) -> Option<AuthMetricsSnapshot> {
477        self.metrics.as_ref().map(|m| m.snapshot())
478    }
479
480    fn extract_token<'a>(&self, request: &'a ConnectionAuthRequest) -> Option<&'a str> {
481        request
482            .bearer_token()
483            .or_else(|| request.query_param(&self.query_param_name))
484    }
485
486    /// Verify a token for in-band refresh and return the auth context
487    ///
488    /// This is used when a client wants to refresh their auth without reconnecting.
489    /// The origin is NOT validated here - we assume the client has already proven
490    /// origin at connection time, and we're just refreshing the session token.
491    pub async fn verify_refresh_token(&self, token: &str) -> Result<AuthContext, AuthDeny> {
492        let result = match &self.verifier {
493            SignedSessionVerifier::Static(verifier) => verifier.verify(token, None, None),
494            SignedSessionVerifier::CachedJwks(verifier) => {
495                verifier.verify_with_cache(token, None, None).await
496            }
497            SignedSessionVerifier::MultiKey(verifier) => verifier.verify(token, None, None).await,
498        };
499
500        match result {
501            Ok(context) => Ok(context),
502            Err(e) => Err(AuthDeny::from_verify_error(e)),
503        }
504    }
505}
506
507#[async_trait]
508impl WebSocketAuthPlugin for SignedSessionAuthPlugin {
509    async fn authorize(&self, request: &ConnectionAuthRequest) -> AuthDecision {
510        let token = match self.extract_token(request) {
511            Some(token) => token,
512            None => {
513                return AuthDecision::Deny(AuthDeny::token_missing());
514            }
515        };
516
517        let expected_origin = if self.require_origin {
518            request.origin.as_deref()
519        } else {
520            None
521        };
522
523        let expected_client_ip = None; // IP validation can be added here if needed
524
525        let result = match &self.verifier {
526            SignedSessionVerifier::Static(verifier) => {
527                verifier.verify(token, expected_origin, expected_client_ip)
528            }
529            SignedSessionVerifier::CachedJwks(verifier) => {
530                verifier
531                    .verify_with_cache(token, expected_origin, expected_client_ip)
532                    .await
533            }
534            SignedSessionVerifier::MultiKey(verifier) => {
535                verifier
536                    .verify(token, expected_origin, expected_client_ip)
537                    .await
538            }
539        };
540
541        match result {
542            Ok(context) => {
543                // Log successful authentication
544                let event = auth_success_event(&context.subject)
545                    .with_client_ip(request.remote_addr)
546                    .with_path(&request.path);
547                if let Some(origin) = &request.origin {
548                    let event = event.with_origin(origin.clone());
549                    self.log_audit(event).await;
550                } else {
551                    self.log_audit(event).await;
552                }
553                AuthDecision::Allow(context)
554            }
555            Err(e) => {
556                let deny = AuthDeny::from_verify_error(e);
557                // Log failed authentication
558                let event = auth_failure_event(&deny.code, &deny.reason)
559                    .with_client_ip(request.remote_addr)
560                    .with_path(&request.path);
561                let event = if let Some(origin) = &request.origin {
562                    event.with_origin(origin.clone())
563                } else {
564                    event
565                };
566                self.log_audit(event).await;
567                AuthDecision::Deny(deny)
568            }
569        }
570    }
571
572    fn as_any(&self) -> &dyn Any {
573        self
574    }
575
576    fn audit_logger(&self) -> Option<&dyn SecurityAuditLogger> {
577        self.audit_logger.as_ref().map(|l| l.as_ref())
578    }
579
580    fn auth_metrics(&self) -> Option<&AuthMetrics> {
581        self.metrics.as_ref().map(|m| m.as_ref())
582    }
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    #[test]
590    fn extracts_bearer_and_query_tokens() {
591        let request = Request::builder()
592            .uri("/ws?token=query-token")
593            .header("Authorization", "Bearer header-token")
594            .body(())
595            .expect("request should build");
596
597        let auth_request = ConnectionAuthRequest::from_http_request(
598            "127.0.0.1:8877".parse().expect("socket addr should parse"),
599            &request,
600        );
601
602        assert_eq!(auth_request.bearer_token(), Some("header-token"));
603        assert_eq!(auth_request.query_param("token"), Some("query-token"));
604    }
605
606    #[tokio::test]
607    async fn static_token_plugin_allows_matching_token() {
608        let plugin = StaticTokenAuthPlugin::new(["secret".to_string()]);
609        let request = Request::builder()
610            .uri("/ws?token=secret")
611            .body(())
612            .expect("request should build");
613        let auth_request = ConnectionAuthRequest::from_http_request(
614            "127.0.0.1:8877".parse().expect("socket addr should parse"),
615            &request,
616        );
617
618        let decision = plugin.authorize(&auth_request).await;
619        assert!(decision.is_allowed());
620        assert!(decision.auth_context().is_some());
621    }
622
623    #[tokio::test]
624    async fn static_token_plugin_denies_missing_token() {
625        let plugin = StaticTokenAuthPlugin::new(["secret".to_string()]);
626        let request = Request::builder()
627            .uri("/ws")
628            .body(())
629            .expect("request should build");
630        let auth_request = ConnectionAuthRequest::from_http_request(
631            "127.0.0.1:8877".parse().expect("socket addr should parse"),
632            &request,
633        );
634
635        let decision = plugin.authorize(&auth_request).await;
636        assert!(!decision.is_allowed());
637    }
638
639    #[tokio::test]
640    async fn allow_all_plugin_allows_with_context() {
641        let plugin = AllowAllAuthPlugin;
642        let request = Request::builder()
643            .uri("/ws")
644            .body(())
645            .expect("request should build");
646        let auth_request = ConnectionAuthRequest::from_http_request(
647            "127.0.0.1:8877".parse().expect("socket addr should parse"),
648            &request,
649        );
650
651        let decision = plugin.authorize(&auth_request).await;
652        assert!(decision.is_allowed());
653        let ctx = decision.auth_context().unwrap();
654        assert_eq!(ctx.subject, "anonymous");
655    }
656
657    // Integration tests for handshake auth failures
658
659    #[tokio::test]
660    async fn signed_session_plugin_denies_missing_token() {
661        use hyperstack_auth::TokenSigner;
662
663        let signing_key = hyperstack_auth::SigningKey::generate();
664        let verifying_key = signing_key.verifying_key();
665        let verifier =
666            hyperstack_auth::TokenVerifier::new(verifying_key, "test-issuer", "test-audience");
667        let plugin = SignedSessionAuthPlugin::new(verifier);
668
669        let request = Request::builder()
670            .uri("/ws")
671            .body(())
672            .expect("request should build");
673        let auth_request = ConnectionAuthRequest::from_http_request(
674            "127.0.0.1:8877".parse().expect("socket addr should parse"),
675            &request,
676        );
677
678        let decision = plugin.authorize(&auth_request).await;
679        assert!(!decision.is_allowed());
680
681        if let AuthDecision::Deny(deny) = decision {
682            assert_eq!(deny.code, AuthErrorCode::TokenMissing);
683        } else {
684            panic!("Expected Deny decision");
685        }
686    }
687
688    #[tokio::test]
689    async fn signed_session_plugin_denies_expired_token() {
690        use hyperstack_auth::{KeyClass, SessionClaims, TokenSigner};
691        use std::time::{SystemTime, UNIX_EPOCH};
692
693        let signing_key = hyperstack_auth::SigningKey::generate();
694        let verifying_key = signing_key.verifying_key();
695        let signer = TokenSigner::new(signing_key, "test-issuer");
696        let verifier =
697            hyperstack_auth::TokenVerifier::new(verifying_key, "test-issuer", "test-audience");
698        let plugin = SignedSessionAuthPlugin::new(verifier);
699
700        // Create a token that expired 1 hour ago
701        let now = SystemTime::now()
702            .duration_since(UNIX_EPOCH)
703            .unwrap()
704            .as_secs();
705        let claims = SessionClaims::builder("test-issuer", "test-subject", "test-audience")
706            .with_scope("read")
707            .with_key_class(KeyClass::Secret)
708            .build();
709
710        // Manually create expired claims
711        let mut expired_claims = claims;
712        expired_claims.exp = now - 3600; // Expired 1 hour ago
713        expired_claims.iat = now - 7200; // Issued 2 hours ago
714        expired_claims.nbf = now - 7200;
715
716        let token = signer.sign(expired_claims).unwrap();
717
718        let request = Request::builder()
719            .uri(format!("/ws?hs_token={}", token))
720            .body(())
721            .expect("request should build");
722        let auth_request = ConnectionAuthRequest::from_http_request(
723            "127.0.0.1:8877".parse().expect("socket addr should parse"),
724            &request,
725        );
726
727        let decision = plugin.authorize(&auth_request).await;
728        assert!(!decision.is_allowed());
729
730        if let AuthDecision::Deny(deny) = decision {
731            assert_eq!(deny.code, AuthErrorCode::TokenExpired);
732        } else {
733            panic!("Expected Deny decision for expired token");
734        }
735    }
736
737    #[tokio::test]
738    async fn signed_session_plugin_denies_invalid_signature() {
739        use hyperstack_auth::{KeyClass, SessionClaims, TokenSigner};
740
741        // Create two different key pairs
742        let signing_key = hyperstack_auth::SigningKey::generate();
743        let wrong_key = hyperstack_auth::SigningKey::generate();
744
745        // Sign with one key, verify with another
746        let signer = TokenSigner::new(signing_key, "test-issuer");
747        let wrong_verifying_key = wrong_key.verifying_key();
748        let verifier = hyperstack_auth::TokenVerifier::new(
749            wrong_verifying_key,
750            "test-issuer",
751            "test-audience",
752        );
753        let plugin = SignedSessionAuthPlugin::new(verifier);
754
755        let claims = SessionClaims::builder("test-issuer", "test-subject", "test-audience")
756            .with_scope("read")
757            .with_key_class(KeyClass::Secret)
758            .build();
759
760        let token = signer.sign(claims).unwrap();
761
762        let request = Request::builder()
763            .uri(format!("/ws?hs_token={}", token))
764            .body(())
765            .expect("request should build");
766        let auth_request = ConnectionAuthRequest::from_http_request(
767            "127.0.0.1:8877".parse().expect("socket addr should parse"),
768            &request,
769        );
770
771        let decision = plugin.authorize(&auth_request).await;
772        assert!(!decision.is_allowed());
773
774        if let AuthDecision::Deny(deny) = decision {
775            assert_eq!(deny.code, AuthErrorCode::TokenInvalidSignature);
776        } else {
777            panic!("Expected Deny decision for invalid signature");
778        }
779    }
780
781    #[tokio::test]
782    async fn signed_session_plugin_denies_wrong_audience() {
783        use hyperstack_auth::{KeyClass, SessionClaims, TokenSigner};
784
785        let signing_key = hyperstack_auth::SigningKey::generate();
786        let verifying_key = signing_key.verifying_key();
787        let signer = TokenSigner::new(signing_key, "test-issuer");
788
789        // Verifier expects "test-audience", token is for "wrong-audience"
790        let verifier =
791            hyperstack_auth::TokenVerifier::new(verifying_key, "test-issuer", "test-audience");
792        let plugin = SignedSessionAuthPlugin::new(verifier);
793
794        let claims = SessionClaims::builder("test-issuer", "test-subject", "wrong-audience")
795            .with_scope("read")
796            .with_key_class(KeyClass::Secret)
797            .build();
798
799        let token = signer.sign(claims).unwrap();
800
801        let request = Request::builder()
802            .uri(format!("/ws?hs_token={}", token))
803            .body(())
804            .expect("request should build");
805        let auth_request = ConnectionAuthRequest::from_http_request(
806            "127.0.0.1:8877".parse().expect("socket addr should parse"),
807            &request,
808        );
809
810        let decision = plugin.authorize(&auth_request).await;
811        assert!(!decision.is_allowed());
812
813        if let AuthDecision::Deny(deny) = decision {
814            assert_eq!(deny.code, AuthErrorCode::TokenInvalidAudience);
815        } else {
816            panic!("Expected Deny decision for wrong audience");
817        }
818    }
819
820    #[tokio::test]
821    async fn signed_session_plugin_denies_origin_mismatch() {
822        use hyperstack_auth::{KeyClass, SessionClaims, TokenSigner};
823
824        let signing_key = hyperstack_auth::SigningKey::generate();
825        let verifying_key = signing_key.verifying_key();
826        let signer = TokenSigner::new(signing_key, "test-issuer");
827
828        // Verifier requires origin validation
829        let verifier =
830            hyperstack_auth::TokenVerifier::new(verifying_key, "test-issuer", "test-audience")
831                .with_origin_validation();
832        let plugin = SignedSessionAuthPlugin::new(verifier).with_origin_validation();
833
834        // Token bound to specific origin
835        let claims = SessionClaims::builder("test-issuer", "test-subject", "test-audience")
836            .with_scope("read")
837            .with_key_class(KeyClass::Secret)
838            .with_origin("https://allowed.example.com")
839            .build();
840
841        let token = signer.sign(claims).unwrap();
842
843        // Request from different origin
844        let request = Request::builder()
845            .uri(format!("/ws?hs_token={}", token))
846            .header("Origin", "https://evil.example.com")
847            .body(())
848            .expect("request should build");
849        let auth_request = ConnectionAuthRequest::from_http_request(
850            "127.0.0.1:8877".parse().expect("socket addr should parse"),
851            &request,
852        );
853
854        let decision = plugin.authorize(&auth_request).await;
855        assert!(!decision.is_allowed());
856
857        if let AuthDecision::Deny(deny) = decision {
858            assert_eq!(deny.code, AuthErrorCode::OriginMismatch);
859        } else {
860            panic!("Expected Deny decision for origin mismatch");
861        }
862    }
863
864    #[tokio::test]
865    async fn signed_session_plugin_allows_valid_token() {
866        use hyperstack_auth::{KeyClass, SessionClaims, TokenSigner};
867
868        let signing_key = hyperstack_auth::SigningKey::generate();
869        let verifying_key = signing_key.verifying_key();
870        let signer = TokenSigner::new(signing_key, "test-issuer");
871        let verifier =
872            hyperstack_auth::TokenVerifier::new(verifying_key, "test-issuer", "test-audience");
873        let plugin = SignedSessionAuthPlugin::new(verifier);
874
875        let claims = SessionClaims::builder("test-issuer", "test-subject", "test-audience")
876            .with_scope("read")
877            .with_key_class(KeyClass::Secret)
878            .with_metering_key("meter-123")
879            .build();
880
881        let token = signer.sign(claims).unwrap();
882
883        let request = Request::builder()
884            .uri(format!("/ws?hs_token={}", token))
885            .body(())
886            .expect("request should build");
887        let auth_request = ConnectionAuthRequest::from_http_request(
888            "127.0.0.1:8877".parse().expect("socket addr should parse"),
889            &request,
890        );
891
892        let decision = plugin.authorize(&auth_request).await;
893        assert!(decision.is_allowed());
894
895        if let AuthDecision::Allow(ctx) = decision {
896            assert_eq!(ctx.subject, "test-subject");
897            assert_eq!(ctx.metering_key, "meter-123");
898            assert_eq!(ctx.key_class, KeyClass::Secret);
899        } else {
900            panic!("Expected Allow decision");
901        }
902    }
903
904    #[tokio::test]
905    async fn signed_session_plugin_allows_with_matching_origin() {
906        use hyperstack_auth::{KeyClass, SessionClaims, TokenSigner};
907
908        let signing_key = hyperstack_auth::SigningKey::generate();
909        let verifying_key = signing_key.verifying_key();
910        let signer = TokenSigner::new(signing_key, "test-issuer");
911
912        let verifier =
913            hyperstack_auth::TokenVerifier::new(verifying_key, "test-issuer", "test-audience")
914                .with_origin_validation();
915        let plugin = SignedSessionAuthPlugin::new(verifier).with_origin_validation();
916
917        let claims = SessionClaims::builder("test-issuer", "test-subject", "test-audience")
918            .with_scope("read")
919            .with_key_class(KeyClass::Secret)
920            .with_origin("https://trusted.example.com")
921            .build();
922
923        let token = signer.sign(claims).unwrap();
924
925        let request = Request::builder()
926            .uri(format!("/ws?hs_token={}", token))
927            .header("Origin", "https://trusted.example.com")
928            .body(())
929            .expect("request should build");
930        let auth_request = ConnectionAuthRequest::from_http_request(
931            "127.0.0.1:8877".parse().expect("socket addr should parse"),
932            &request,
933        );
934
935        let decision = plugin.authorize(&auth_request).await;
936        assert!(decision.is_allowed());
937
938        if let AuthDecision::Allow(ctx) = decision {
939            assert_eq!(ctx.origin, Some("https://trusted.example.com".to_string()));
940        } else {
941            panic!("Expected Allow decision");
942        }
943    }
944
945    // Tests for AuthErrorCode utility methods
946    #[test]
947    fn auth_error_code_should_retry_logic() {
948        assert!(AuthErrorCode::RateLimitExceeded.should_retry());
949        assert!(AuthErrorCode::InternalError.should_retry());
950        assert!(!AuthErrorCode::TokenExpired.should_retry());
951        assert!(!AuthErrorCode::TokenInvalidSignature.should_retry());
952        assert!(!AuthErrorCode::TokenMissing.should_retry());
953    }
954
955    #[test]
956    fn auth_error_code_should_refresh_token_logic() {
957        assert!(AuthErrorCode::TokenExpired.should_refresh_token());
958        assert!(AuthErrorCode::TokenInvalidSignature.should_refresh_token());
959        assert!(AuthErrorCode::TokenInvalidFormat.should_refresh_token());
960        assert!(AuthErrorCode::TokenInvalidIssuer.should_refresh_token());
961        assert!(AuthErrorCode::TokenInvalidAudience.should_refresh_token());
962        assert!(AuthErrorCode::TokenKeyNotFound.should_refresh_token());
963        assert!(!AuthErrorCode::TokenMissing.should_refresh_token());
964        assert!(!AuthErrorCode::RateLimitExceeded.should_refresh_token());
965        assert!(!AuthErrorCode::ConnectionLimitExceeded.should_refresh_token());
966    }
967
968    #[test]
969    fn auth_error_code_string_representation() {
970        assert_eq!(AuthErrorCode::TokenMissing.as_str(), "token-missing");
971        assert_eq!(AuthErrorCode::TokenExpired.as_str(), "token-expired");
972        assert_eq!(
973            AuthErrorCode::TokenInvalidSignature.as_str(),
974            "token-invalid-signature"
975        );
976        assert_eq!(
977            AuthErrorCode::RateLimitExceeded.as_str(),
978            "rate-limit-exceeded"
979        );
980        assert_eq!(
981            AuthErrorCode::ConnectionLimitExceeded.as_str(),
982            "connection-limit-exceeded"
983        );
984    }
985
986    // Tests for AuthDeny construction
987    #[test]
988    fn auth_deny_token_missing_factory() {
989        let deny = AuthDeny::token_missing();
990        assert_eq!(deny.code, AuthErrorCode::TokenMissing);
991        assert!(deny.reason.contains("Missing session token"));
992    }
993
994    #[test]
995    fn auth_deny_from_verify_error_mapping() {
996        use hyperstack_auth::VerifyError;
997
998        let test_cases = vec![
999            (VerifyError::Expired, AuthErrorCode::TokenExpired),
1000            (
1001                VerifyError::InvalidSignature,
1002                AuthErrorCode::TokenInvalidSignature,
1003            ),
1004            (
1005                VerifyError::InvalidIssuer,
1006                AuthErrorCode::TokenInvalidIssuer,
1007            ),
1008            (
1009                VerifyError::InvalidAudience,
1010                AuthErrorCode::TokenInvalidAudience,
1011            ),
1012            (
1013                VerifyError::KeyNotFound("kid123".to_string()),
1014                AuthErrorCode::TokenKeyNotFound,
1015            ),
1016            (
1017                VerifyError::OriginMismatch {
1018                    expected: "a".to_string(),
1019                    actual: "b".to_string(),
1020                },
1021                AuthErrorCode::OriginMismatch,
1022            ),
1023        ];
1024
1025        for (err, expected_code) in test_cases {
1026            let deny = AuthDeny::from_verify_error(err);
1027            assert_eq!(deny.code, expected_code);
1028        }
1029    }
1030
1031    // Tests for multiple auth failure scenarios in sequence
1032    #[tokio::test]
1033    async fn signed_session_plugin_handles_multiple_failure_reasons() {
1034        use hyperstack_auth::{KeyClass, SessionClaims, TokenSigner};
1035
1036        let signing_key = hyperstack_auth::SigningKey::generate();
1037        let verifying_key = signing_key.verifying_key();
1038        let signer = TokenSigner::new(signing_key, "test-issuer");
1039        let verifier =
1040            hyperstack_auth::TokenVerifier::new(verifying_key, "test-issuer", "test-audience")
1041                .with_origin_validation();
1042        let plugin = SignedSessionAuthPlugin::new(verifier).with_origin_validation();
1043
1044        // Test 1: Missing token
1045        let request = Request::builder()
1046            .uri("/ws")
1047            .body(())
1048            .expect("request should build");
1049        let auth_request = ConnectionAuthRequest::from_http_request(
1050            "127.0.0.1:8877".parse().expect("socket addr should parse"),
1051            &request,
1052        );
1053        let decision = plugin.authorize(&auth_request).await;
1054        assert!(!decision.is_allowed());
1055        match decision {
1056            AuthDecision::Deny(deny) => assert_eq!(deny.code, AuthErrorCode::TokenMissing),
1057            _ => panic!("Expected Deny decision"),
1058        }
1059
1060        // Test 2: Valid token with wrong origin
1061        let claims = SessionClaims::builder("test-issuer", "test-subject", "test-audience")
1062            .with_scope("read")
1063            .with_key_class(KeyClass::Secret)
1064            .with_origin("https://allowed.example.com")
1065            .build();
1066        let token = signer.sign(claims).unwrap();
1067
1068        let request = Request::builder()
1069            .uri(format!("/ws?hs_token={}", token))
1070            .header("Origin", "https://evil.example.com")
1071            .body(())
1072            .expect("request should build");
1073        let auth_request = ConnectionAuthRequest::from_http_request(
1074            "127.0.0.1:8877".parse().expect("socket addr should parse"),
1075            &request,
1076        );
1077        let decision = plugin.authorize(&auth_request).await;
1078        assert!(!decision.is_allowed());
1079        match decision {
1080            AuthDecision::Deny(deny) => assert_eq!(deny.code, AuthErrorCode::OriginMismatch),
1081            _ => panic!("Expected Deny decision for origin mismatch"),
1082        }
1083
1084        // Test 3: Valid token with correct origin
1085        let claims = SessionClaims::builder("test-issuer", "test-subject", "test-audience")
1086            .with_scope("read")
1087            .with_key_class(KeyClass::Secret)
1088            .with_origin("https://allowed.example.com")
1089            .build();
1090        let token = signer.sign(claims).unwrap();
1091
1092        let request = Request::builder()
1093            .uri(format!("/ws?hs_token={}", token))
1094            .header("Origin", "https://allowed.example.com")
1095            .body(())
1096            .expect("request should build");
1097        let auth_request = ConnectionAuthRequest::from_http_request(
1098            "127.0.0.1:8877".parse().expect("socket addr should parse"),
1099            &request,
1100        );
1101        let decision = plugin.authorize(&auth_request).await;
1102        assert!(decision.is_allowed());
1103    }
1104
1105    // Test for rate limit error code
1106    #[tokio::test]
1107    async fn auth_deney_with_rate_limit_code() {
1108        let deny = AuthDeny::new(
1109            AuthErrorCode::RateLimitExceeded,
1110            "Too many requests from this IP",
1111        );
1112        assert_eq!(deny.code, AuthErrorCode::RateLimitExceeded);
1113        assert!(deny.code.should_retry());
1114        assert!(!deny.code.should_refresh_token());
1115    }
1116
1117    // Test for connection limit error code
1118    #[tokio::test]
1119    async fn auth_deny_with_connection_limit_code() {
1120        let deny = AuthDeny::new(
1121            AuthErrorCode::ConnectionLimitExceeded,
1122            "Maximum connections exceeded for subject user-123",
1123        );
1124        assert_eq!(deny.code, AuthErrorCode::ConnectionLimitExceeded);
1125        assert!(!deny.code.should_retry());
1126        assert!(!deny.code.should_refresh_token());
1127    }
1128
1129    // Integration-style test: Token extraction from various sources
1130    #[test]
1131    fn token_extraction_priority() {
1132        // Header takes priority over query param
1133        let request = Request::builder()
1134            .uri("/ws?hs_token=query-value")
1135            .header("Authorization", "Bearer header-value")
1136            .body(())
1137            .expect("request should build");
1138        let auth_request = ConnectionAuthRequest::from_http_request(
1139            "127.0.0.1:8877".parse().expect("socket addr should parse"),
1140            &request,
1141        );
1142
1143        // bearer_token should return header value
1144        assert_eq!(auth_request.bearer_token(), Some("header-value"));
1145        // query_param should return query value
1146        assert_eq!(auth_request.query_param("hs_token"), Some("query-value"));
1147    }
1148
1149    // Test malformed authorization header handling
1150    #[test]
1151    fn malformed_authorization_header() {
1152        let test_cases = vec![
1153            ("Basic dXNlcjpwYXNz", None),                // Wrong scheme
1154            ("Bearer", None),                            // Missing token (no space after Bearer)
1155            ("", None),                                  // Empty
1156            ("Bearer token extra", Some("token extra")), // Extra parts (token includes everything after scheme)
1157        ];
1158
1159        for (header_value, expected) in test_cases {
1160            let request = Request::builder()
1161                .uri("/ws")
1162                .header("Authorization", header_value)
1163                .body(())
1164                .expect("request should build");
1165            let auth_request = ConnectionAuthRequest::from_http_request(
1166                "127.0.0.1:8877".parse().expect("socket addr should parse"),
1167                &request,
1168            );
1169            assert_eq!(
1170                auth_request.bearer_token(),
1171                expected,
1172                "Failed for header: {}",
1173                header_value
1174            );
1175        }
1176    }
1177
1178    // ============================================
1179    // WEBSOCKET HANDSHAKE AUTH FAILURE TESTS
1180    // ============================================
1181    // These tests simulate real-world handshake failure scenarios
1182
1183    #[test]
1184    fn auth_deny_error_response_structure() {
1185        let deny = AuthDeny::new(AuthErrorCode::TokenExpired, "Token has expired")
1186            .with_field("exp")
1187            .with_context("Token expired 5 minutes ago")
1188            .with_suggested_action("Refresh your authentication token")
1189            .with_docs_url("https://docs.usehyperstack.com/auth/errors#token-expired");
1190
1191        let response = deny.to_error_response();
1192
1193        assert_eq!(response.code, "token-expired");
1194        assert_eq!(response.message, "Token has expired");
1195        assert_eq!(response.error, "token-expired");
1196        assert!(response.retryable);
1197        assert_eq!(
1198            response.suggested_action,
1199            Some("Refresh your authentication token".to_string())
1200        );
1201        assert_eq!(
1202            response.docs_url,
1203            Some("https://docs.usehyperstack.com/auth/errors#token-expired".to_string())
1204        );
1205    }
1206
1207    #[test]
1208    fn auth_deny_rate_limited_response() {
1209        use std::time::Duration;
1210
1211        let deny = AuthDeny::rate_limited(Duration::from_secs(30), "websocket connections");
1212        let response = deny.to_error_response();
1213
1214        assert_eq!(response.code, "rate-limit-exceeded");
1215        assert!(response.message.contains("30s"));
1216        assert!(response.retryable);
1217        assert_eq!(response.retry_after, Some(30));
1218    }
1219
1220    #[test]
1221    fn auth_deny_connection_limit_response() {
1222        let deny = AuthDeny::connection_limit_exceeded("user-123", 5, 5);
1223        let response = deny.to_error_response();
1224
1225        assert_eq!(response.code, "connection-limit-exceeded");
1226        assert!(response.message.contains("user-123"));
1227        assert!(response.message.contains("5 of 5"));
1228        assert!(response.retryable); // Connection limits are retryable (may become available)
1229    }
1230
1231    #[test]
1232    fn retry_policy_immediate() {
1233        let deny = AuthDeny::new(AuthErrorCode::InternalError, "Transient error")
1234            .with_retry_policy(RetryPolicy::RetryImmediately);
1235
1236        assert_eq!(deny.retry_policy, RetryPolicy::RetryImmediately);
1237    }
1238
1239    #[test]
1240    fn retry_policy_with_backoff() {
1241        use std::time::Duration;
1242
1243        let deny = AuthDeny::new(AuthErrorCode::RateLimitExceeded, "Too many requests")
1244            .with_retry_policy(RetryPolicy::RetryWithBackoff {
1245                initial: Duration::from_secs(1),
1246                max: Duration::from_secs(60),
1247            });
1248
1249        match deny.retry_policy {
1250            RetryPolicy::RetryWithBackoff { initial, max } => {
1251                assert_eq!(initial, Duration::from_secs(1));
1252                assert_eq!(max, Duration::from_secs(60));
1253            }
1254            _ => panic!("Expected RetryWithBackoff"),
1255        }
1256    }
1257
1258    #[test]
1259    fn auth_error_code_http_status_mapping() {
1260        assert_eq!(AuthErrorCode::TokenMissing.http_status(), 401);
1261        assert_eq!(AuthErrorCode::TokenExpired.http_status(), 401);
1262        assert_eq!(AuthErrorCode::TokenInvalidSignature.http_status(), 401);
1263        assert_eq!(AuthErrorCode::OriginMismatch.http_status(), 403);
1264        assert_eq!(AuthErrorCode::RateLimitExceeded.http_status(), 429);
1265        assert_eq!(AuthErrorCode::ConnectionLimitExceeded.http_status(), 429);
1266        assert_eq!(AuthErrorCode::InternalError.http_status(), 500);
1267    }
1268
1269    #[test]
1270    fn auth_error_code_default_retry_policies() {
1271        use std::time::Duration;
1272
1273        // Should refresh token
1274        assert!(matches!(
1275            AuthErrorCode::TokenExpired.default_retry_policy(),
1276            RetryPolicy::RetryWithFreshToken
1277        ));
1278        assert!(matches!(
1279            AuthErrorCode::TokenInvalidSignature.default_retry_policy(),
1280            RetryPolicy::RetryWithFreshToken
1281        ));
1282
1283        // Should retry with backoff
1284        assert!(matches!(
1285            AuthErrorCode::RateLimitExceeded.default_retry_policy(),
1286            RetryPolicy::RetryWithBackoff { .. }
1287        ));
1288        assert!(matches!(
1289            AuthErrorCode::InternalError.default_retry_policy(),
1290            RetryPolicy::RetryWithBackoff { .. }
1291        ));
1292
1293        // Should not retry
1294        assert!(matches!(
1295            AuthErrorCode::TokenMissing.default_retry_policy(),
1296            RetryPolicy::NoRetry
1297        ));
1298        assert!(matches!(
1299            AuthErrorCode::OriginMismatch.default_retry_policy(),
1300            RetryPolicy::NoRetry
1301        ));
1302    }
1303
1304    // Simulated handshake scenarios
1305
1306    #[tokio::test]
1307    async fn handshake_rejects_missing_token_with_proper_error() {
1308        use tokio_tungstenite::tungstenite::http::StatusCode;
1309
1310        let plugin = AllowAllAuthPlugin;
1311
1312        // Create a request without a token
1313        let request = Request::builder()
1314            .uri("/ws")
1315            .body(())
1316            .expect("request should build");
1317
1318        let auth_request = ConnectionAuthRequest::from_http_request(
1319            "127.0.0.1:8877".parse().expect("socket addr should parse"),
1320            &request,
1321        );
1322
1323        // For this test, we'll use a plugin that requires tokens
1324        // Actually AllowAllAuthPlugin doesn't require tokens, so let's create a static token plugin
1325        let static_plugin = StaticTokenAuthPlugin::new(["valid-token".to_string()]);
1326        let decision = static_plugin.authorize(&auth_request).await;
1327
1328        assert!(!decision.is_allowed());
1329
1330        if let AuthDecision::Deny(deny) = decision {
1331            assert_eq!(deny.code, AuthErrorCode::TokenMissing);
1332            assert_eq!(deny.http_status, 401);
1333            assert!(deny.reason.contains("Missing"));
1334        } else {
1335            panic!("Expected Deny decision");
1336        }
1337    }
1338
1339    #[tokio::test]
1340    async fn handshake_rejects_expired_token_with_retry_hint() {
1341        use hyperstack_auth::{KeyClass, SessionClaims, TokenSigner};
1342        use std::time::{SystemTime, UNIX_EPOCH};
1343
1344        let signing_key = hyperstack_auth::SigningKey::generate();
1345        let verifying_key = signing_key.verifying_key();
1346        let signer = TokenSigner::new(signing_key, "test-issuer");
1347
1348        // Create an expired token
1349        let now = SystemTime::now()
1350            .duration_since(UNIX_EPOCH)
1351            .unwrap()
1352            .as_secs();
1353        let claims = SessionClaims::builder("test-issuer", "test-subject", "test-audience")
1354            .with_scope("read")
1355            .with_key_class(KeyClass::Secret)
1356            .build();
1357
1358        let mut expired_claims = claims;
1359        expired_claims.exp = now - 3600;
1360        expired_claims.iat = now - 7200;
1361        expired_claims.nbf = now - 7200;
1362
1363        let token = signer.sign(expired_claims).unwrap();
1364
1365        // Create verifier and plugin
1366        let verifier =
1367            hyperstack_auth::TokenVerifier::new(verifying_key, "test-issuer", "test-audience");
1368        let plugin = SignedSessionAuthPlugin::new(verifier);
1369
1370        let request = Request::builder()
1371            .uri(format!("/ws?hs_token={}", token))
1372            .body(())
1373            .expect("request should build");
1374
1375        let auth_request = ConnectionAuthRequest::from_http_request(
1376            "127.0.0.1:8877".parse().expect("socket addr should parse"),
1377            &request,
1378        );
1379
1380        let decision = plugin.authorize(&auth_request).await;
1381
1382        assert!(!decision.is_allowed());
1383
1384        if let AuthDecision::Deny(deny) = decision {
1385            assert_eq!(deny.code, AuthErrorCode::TokenExpired);
1386            assert_eq!(deny.http_status, 401);
1387            // Should suggest refreshing the token
1388            assert!(matches!(
1389                deny.retry_policy,
1390                RetryPolicy::RetryWithFreshToken
1391            ));
1392        } else {
1393            panic!("Expected Deny decision");
1394        }
1395    }
1396
1397    #[tokio::test]
1398    async fn handshake_rejects_invalid_signature_with_retry_hint() {
1399        use hyperstack_auth::{KeyClass, SessionClaims, TokenSigner};
1400
1401        // Create two different key pairs
1402        let signing_key = hyperstack_auth::SigningKey::generate();
1403        let wrong_key = hyperstack_auth::SigningKey::generate();
1404
1405        // Sign with one key, verify with another
1406        let signer = TokenSigner::new(signing_key, "test-issuer");
1407        let wrong_verifying_key = wrong_key.verifying_key();
1408        let verifier = hyperstack_auth::TokenVerifier::new(
1409            wrong_verifying_key,
1410            "test-issuer",
1411            "test-audience",
1412        );
1413        let plugin = SignedSessionAuthPlugin::new(verifier);
1414
1415        let claims = SessionClaims::builder("test-issuer", "test-subject", "test-audience")
1416            .with_scope("read")
1417            .with_key_class(KeyClass::Secret)
1418            .build();
1419
1420        let token = signer.sign(claims).unwrap();
1421
1422        let request = Request::builder()
1423            .uri(format!("/ws?hs_token={}", token))
1424            .body(())
1425            .expect("request should build");
1426
1427        let auth_request = ConnectionAuthRequest::from_http_request(
1428            "127.0.0.1:8877".parse().expect("socket addr should parse"),
1429            &request,
1430        );
1431
1432        let decision = plugin.authorize(&auth_request).await;
1433
1434        assert!(!decision.is_allowed());
1435
1436        if let AuthDecision::Deny(deny) = decision {
1437            assert_eq!(deny.code, AuthErrorCode::TokenInvalidSignature);
1438            assert_eq!(deny.http_status, 401);
1439            // Should suggest refreshing the token
1440            assert!(matches!(
1441                deny.retry_policy,
1442                RetryPolicy::RetryWithFreshToken
1443            ));
1444        } else {
1445            panic!("Expected Deny decision");
1446        }
1447    }
1448
1449    #[tokio::test]
1450    async fn handshake_rejects_origin_mismatch_without_retry() {
1451        use hyperstack_auth::{KeyClass, SessionClaims, TokenSigner};
1452
1453        let signing_key = hyperstack_auth::SigningKey::generate();
1454        let verifying_key = signing_key.verifying_key();
1455        let signer = TokenSigner::new(signing_key, "test-issuer");
1456
1457        let verifier =
1458            hyperstack_auth::TokenVerifier::new(verifying_key, "test-issuer", "test-audience")
1459                .with_origin_validation();
1460        let plugin = SignedSessionAuthPlugin::new(verifier).with_origin_validation();
1461
1462        // Token bound to specific origin
1463        let claims = SessionClaims::builder("test-issuer", "test-subject", "test-audience")
1464            .with_scope("read")
1465            .with_key_class(KeyClass::Secret)
1466            .with_origin("https://allowed.example.com")
1467            .build();
1468
1469        let token = signer.sign(claims).unwrap();
1470
1471        // Request from different origin
1472        let request = Request::builder()
1473            .uri(format!("/ws?hs_token={}", token))
1474            .header("Origin", "https://evil.example.com")
1475            .body(())
1476            .expect("request should build");
1477
1478        let auth_request = ConnectionAuthRequest::from_http_request(
1479            "127.0.0.1:8877".parse().expect("socket addr should parse"),
1480            &request,
1481        );
1482
1483        let decision = plugin.authorize(&auth_request).await;
1484
1485        assert!(!decision.is_allowed());
1486
1487        if let AuthDecision::Deny(deny) = decision {
1488            assert_eq!(deny.code, AuthErrorCode::OriginMismatch);
1489            assert_eq!(deny.http_status, 403);
1490            // Should NOT suggest retrying - this is a security issue
1491            assert!(matches!(deny.retry_policy, RetryPolicy::NoRetry));
1492        } else {
1493            panic!("Expected Deny decision");
1494        }
1495    }
1496
1497    // Test that AuthDeny can be converted to HTTP error response
1498    #[test]
1499    fn auth_deny_to_http_response() {
1500        let deny = AuthDeny::new(AuthErrorCode::RateLimitExceeded, "Too many requests")
1501            .with_suggested_action("Wait before retrying")
1502            .with_retry_policy(RetryPolicy::RetryAfter(Duration::from_secs(30)));
1503
1504        let response = deny.to_error_response();
1505
1506        // Verify the response is serializable
1507        let json = serde_json::to_string(&response).expect("Should serialize");
1508        assert!(json.contains("rate-limit-exceeded"));
1509        assert!(json.contains("Too many requests"));
1510        assert!(json.contains("Wait before retrying"));
1511        assert!(json.contains("\"retryable\":true"));
1512        assert!(json.contains("\"retry_after\":30"));
1513    }
1514
1515    // Test comprehensive error scenarios
1516    #[tokio::test]
1517    async fn comprehensive_auth_error_scenarios() {
1518        use hyperstack_auth::{KeyClass, SessionClaims, TokenSigner};
1519
1520        let signing_key = hyperstack_auth::SigningKey::generate();
1521        let verifying_key = signing_key.verifying_key();
1522        let signer = TokenSigner::new(signing_key, "test-issuer");
1523        let verifier =
1524            hyperstack_auth::TokenVerifier::new(verifying_key, "test-issuer", "test-audience");
1525        let plugin = SignedSessionAuthPlugin::new(verifier);
1526
1527        let test_cases = vec![
1528            ("missing_token", None, AuthErrorCode::TokenMissing),
1529            (
1530                "invalid_format",
1531                Some("not-a-valid-token"),
1532                AuthErrorCode::TokenInvalidFormat,
1533            ),
1534        ];
1535
1536        for (name, token, expected_code) in test_cases {
1537            let uri = token.map_or_else(|| "/ws".to_string(), |t| format!("/ws?hs_token={}", t));
1538
1539            let request = Request::builder()
1540                .uri(&uri)
1541                .body(())
1542                .expect("request should build");
1543
1544            let auth_request = ConnectionAuthRequest::from_http_request(
1545                "127.0.0.1:8877".parse().expect("socket addr should parse"),
1546                &request,
1547            );
1548
1549            let decision = plugin.authorize(&auth_request).await;
1550
1551            assert!(!decision.is_allowed(), "{}: should deny", name);
1552
1553            if let AuthDecision::Deny(deny) = decision {
1554                assert_eq!(deny.code, expected_code, "{}: wrong error code", name);
1555            } else {
1556                panic!("{}: Expected Deny decision", name);
1557            }
1558        }
1559    }
1560}