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
10pub use hyperstack_auth::AuthContext;
12pub use hyperstack_auth::AuthErrorCode;
14pub use hyperstack_auth::RetryPolicy;
16pub use hyperstack_auth::{
18 auth_failure_event, auth_success_event, rate_limit_event, AuditEvent, AuditSeverity,
19 ChannelAuditLogger, NoOpAuditLogger, SecurityAuditEvent, SecurityAuditLogger,
20};
21pub use hyperstack_auth::{AuthMetrics, AuthMetricsCollector, AuthMetricsSnapshot};
23pub 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 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#[derive(Debug, Clone, Default)]
83pub struct AuthErrorDetails {
84 pub field: Option<String>,
86 pub context: Option<String>,
88 pub suggested_action: Option<String>,
90 pub docs_url: Option<String>,
92}
93
94#[derive(Debug, Clone)]
96pub struct AuthDeny {
97 pub reason: String,
98 pub code: AuthErrorCode,
99 pub details: AuthErrorDetails,
101 pub retry_policy: RetryPolicy,
103 pub http_status: u16,
105 pub reset_at: Option<std::time::SystemTime>,
107}
108
109impl AuthDeny {
110 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 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 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 pub fn with_details(mut self, details: AuthErrorDetails) -> Self {
141 self.details = details;
142 self
143 }
144
145 pub fn with_field(mut self, field: impl Into<String>) -> Self {
147 self.details.field = Some(field.into());
148 self
149 }
150
151 pub fn with_context(mut self, context: impl Into<String>) -> Self {
153 self.details.context = Some(context.into());
154 self
155 }
156
157 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 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 pub fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
171 self.retry_policy = policy;
172 self
173 }
174
175 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 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 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 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#[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#[derive(Debug, Clone)]
253pub enum AuthDecision {
254 Allow(AuthContext),
256 Deny(AuthDeny),
258}
259
260impl AuthDecision {
261 pub fn is_allowed(&self) -> bool {
263 matches!(self, AuthDecision::Allow(_))
264 }
265
266 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 fn audit_logger(&self) -> Option<&dyn SecurityAuditLogger> {
283 None
284 }
285
286 async fn log_audit(&self, event: SecurityAuditEvent) {
288 if let Some(logger) = self.audit_logger() {
289 logger.log(event).await;
290 }
291 }
292
293 fn auth_metrics(&self) -> Option<&AuthMetrics> {
295 None
296 }
297}
298
299pub struct AllowAllAuthPlugin;
304
305#[async_trait]
306impl WebSocketAuthPlugin for AllowAllAuthPlugin {
307 async fn authorize(&self, _request: &ConnectionAuthRequest) -> AuthDecision {
308 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, 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 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, 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
397enum 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 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 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 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 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 pub fn with_origin_validation(mut self) -> Self {
459 self.require_origin = true;
460 self
461 }
462
463 pub fn with_audit_logger(mut self, logger: Arc<dyn SecurityAuditLogger>) -> Self {
465 self.audit_logger = Some(logger);
466 self
467 }
468
469 pub fn with_metrics(mut self, metrics: Arc<AuthMetrics>) -> Self {
471 self.metrics = Some(metrics);
472 self
473 }
474
475 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 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; 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 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 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 #[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 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 let mut expired_claims = claims;
712 expired_claims.exp = now - 3600; expired_claims.iat = now - 7200; 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 let signing_key = hyperstack_auth::SigningKey::generate();
743 let wrong_key = hyperstack_auth::SigningKey::generate();
744
745 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 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 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 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 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 #[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 #[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 #[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 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 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 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 #[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 #[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 #[test]
1131 fn token_extraction_priority() {
1132 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 assert_eq!(auth_request.bearer_token(), Some("header-value"));
1145 assert_eq!(auth_request.query_param("hs_token"), Some("query-value"));
1147 }
1148
1149 #[test]
1151 fn malformed_authorization_header() {
1152 let test_cases = vec![
1153 ("Basic dXNlcjpwYXNz", None), ("Bearer", None), ("", None), ("Bearer token extra", Some("token extra")), ];
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 #[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); }
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 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 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 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 #[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 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 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 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 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 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 let signing_key = hyperstack_auth::SigningKey::generate();
1403 let wrong_key = hyperstack_auth::SigningKey::generate();
1404
1405 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 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 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 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 assert!(matches!(deny.retry_policy, RetryPolicy::NoRetry));
1492 } else {
1493 panic!("Expected Deny decision");
1494 }
1495 }
1496
1497 #[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 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 #[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}