1use async_trait::async_trait;
43use bytes::Bytes;
44use serde::{Deserialize, Serialize};
45use std::collections::HashMap;
46use std::sync::Arc;
47use std::time::Duration;
48
49use crate::context::{Context, GatewayError};
50use crate::outbound::{OutboundClient, OutboundRequest};
51use crate::plugins::resources::PluginResources;
52use crate::plugins::util::cookie_session::{read_cookie, CookieAttrs, CookieSealer, SameSite};
53use crate::plugins::util::server_session::{self, SessionBackend};
54use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
55use crate::sessions::StoreError;
56
57#[derive(Debug)]
65enum CasError {
66 Infra(String),
67 Denied(String),
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
72struct CasSession {
73 user: String,
75}
76
77pub struct CasAuthPlugin {
79 idp_uri: String,
81 service: Option<String>,
85 ticket_param: String,
87 ssl_verify: bool,
89 timeout: Duration,
91 sealer: Option<CookieSealer>,
94 cookie_name: String,
96 cookie_path: String,
100 cookie_lifetime: u64,
102 logout_path: Option<String>,
104 backend: SessionBackend,
108 client: Arc<OutboundClient>,
109}
110
111impl CasAuthPlugin {
112 pub fn from_config(
147 config: &HashMap<String, serde_json::Value>,
148 resources: &Arc<PluginResources>,
149 ) -> Result<Self, String> {
150 let idp_uri = config
151 .get("idp_uri")
152 .and_then(|v| v.as_str())
153 .filter(|s| !s.trim().is_empty())
154 .ok_or("cas-auth plugin requires a non-empty 'idp_uri'")?
155 .trim_end_matches('/')
156 .to_string();
157
158 let service = config
159 .get("service")
160 .and_then(|v| v.as_str())
161 .filter(|s| !s.trim().is_empty())
162 .map(String::from);
163
164 let ticket_param = config
165 .get("ticket_param")
166 .and_then(|v| v.as_str())
167 .unwrap_or("ticket")
168 .to_string();
169
170 let ssl_verify = config
171 .get("ssl_verify")
172 .and_then(|v| v.as_bool())
173 .unwrap_or(true);
174
175 let timeout = Duration::from_millis(
176 config
177 .get("timeout_ms")
178 .and_then(|v| v.as_u64())
179 .unwrap_or(3_000),
180 );
181
182 let sealer = session_secret(config).map(|s| CookieSealer::new(&s));
184 let cookie_name =
185 session_cookie_str(config, "name").unwrap_or_else(|| "cas_session".to_string());
186 let cookie_path = session_cookie_str(config, "path").unwrap_or_else(|| "/".to_string());
187 let cookie_lifetime = session_cookie_u64(config, "lifetime").unwrap_or(3_600);
188 let logout_path = config
189 .get("logout_path")
190 .and_then(|v| v.as_str())
191 .filter(|s| !s.is_empty())
192 .map(String::from);
193
194 let backend = server_session::parse_backend(config, resources, "cas-auth")?;
195 if sealer.is_none() && !matches!(backend, SessionBackend::Cookie) {
196 return Err(
197 "cas-auth: session.storage requires session_secret (interactive mode)".to_string(),
198 );
199 }
200
201 Ok(Self {
202 idp_uri,
203 service,
204 ticket_param,
205 ssl_verify,
206 timeout,
207 sealer,
208 cookie_name,
209 cookie_path,
210 cookie_lifetime,
211 logout_path,
212 backend,
213 client: resources.outbound.clone(),
214 })
215 }
216
217 fn reject(&self, ctx: Context, message: &str) -> PluginResult {
219 let mut ctx = ctx;
220 ctx.response.status_code = 401;
221 ctx.response.body = Bytes::from(format!(
222 r#"{{"error": "unauthorized", "message": "{}"}}"#,
223 message
224 ));
225 ctx.response.headers.insert(
226 "content-type".to_string(),
227 vec!["application/json".to_string()],
228 );
229 Ok(PluginOutput::on_port(ctx, "denied"))
230 }
231
232 fn infra_error(&self, ctx: Context, message: String) -> PluginExecutionError {
239 crate::plugins::util::provider_error::provider_error(
240 ctx,
241 "CAS_AUTH_PROVIDER_ERROR",
242 message,
243 )
244 }
245
246 fn store_error(mut ctx: Context, e: StoreError) -> PluginExecutionError {
249 ctx.response.status_code = 503;
250 ctx.response.body = Bytes::from(r#"{"error": "session store unavailable"}"#.as_bytes());
251 ctx.response.headers.insert(
252 "content-type".to_string(),
253 vec!["application/json".to_string()],
254 );
255 PluginExecutionError {
256 context: ctx,
257 error: GatewayError {
258 node_id: String::new(),
259 code: "SESSION_STORE_ERROR".to_string(),
260 message: e.to_string(),
261 metadata: HashMap::new(),
262 },
263 }
264 }
265
266 fn redirect(
270 &self,
271 mut ctx: Context,
272 location: String,
273 set_cookies: Vec<String>,
274 ) -> PluginResult {
275 ctx.response.status_code = 302;
276 ctx.response
277 .headers
278 .insert("location".to_string(), vec![location]);
279 if !set_cookies.is_empty() {
280 ctx.response
281 .headers
282 .insert("set-cookie".to_string(), set_cookies);
283 }
284 ctx.response.body = Bytes::new();
285 Ok(PluginOutput::on_port(ctx, "redirect"))
286 }
287
288 fn service_url(&self, ctx: &Context) -> String {
292 if let Some(ref service) = self.service {
293 return service.clone();
294 }
295 format!(
296 "{}://{}{}",
297 ctx.request.scheme, ctx.request.host, ctx.request.path
298 )
299 }
300
301 fn session_attrs(&self, ctx: &Context) -> CookieAttrs<'_> {
304 CookieAttrs {
305 path: &self.cookie_path,
306 max_age: Some(self.cookie_lifetime),
307 http_only: true,
308 secure: ctx.request.scheme == "https",
309 same_site: SameSite::Lax,
310 }
311 }
312
313 fn attach_user(&self, ctx: &mut Context, user: &str) {
315 ctx.request
316 .headers
317 .insert("x-cas-user".to_string(), vec![user.to_string()]);
318 ctx.message.insert(
319 "user".to_string(),
320 serde_json::Value::String(user.to_string()),
321 );
322 ctx.message.insert(
323 "user_id".to_string(),
324 serde_json::Value::String(user.to_string()),
325 );
326 }
327
328 async fn read_session(&self, ctx: &Context) -> Result<Option<String>, StoreError> {
333 let Some(sealer) = self.sealer.as_ref() else {
334 return Ok(None);
335 };
336 let Some(cookie_header) = ctx.request.headers.get("cookie").and_then(|v| v.first()) else {
337 return Ok(None);
338 };
339 let Some(raw) = read_cookie(cookie_header, &self.cookie_name) else {
340 return Ok(None);
341 };
342 let bytes = server_session::load(&self.backend, sealer, raw).await?;
343 Ok(bytes.and_then(|b| {
344 serde_json::from_slice::<CasSession>(&b)
345 .ok()
346 .map(|s| s.user)
347 }))
348 }
349
350 async fn cas_validate(&self, ctx: &Context, ticket: &str) -> Result<String, CasError> {
356 let service = self.service_url(ctx);
357 let url = build_validate_url(&self.idp_uri, ticket, &service);
358
359 let outbound = OutboundRequest {
360 method: http::Method::GET,
361 url,
362 headers: Vec::new(),
363 body: Bytes::new(),
364 timeout: self.timeout,
365 ssl_verify: self.ssl_verify,
366 tls: None,
367 };
368
369 let response = self
370 .client
371 .request(outbound)
372 .await
373 .map_err(|e| CasError::Infra(format!("CAS validation request failed: {}", e)))?;
374
375 classify_validation(response.status, &response.body)
376 }
377
378 async fn execute_interactive(&self, mut ctx: Context) -> PluginResult {
380 let sealer = self
381 .sealer
382 .as_ref()
383 .expect("execute_interactive only called when a sealer is configured");
384
385 if let Some(ref logout_path) = self.logout_path {
387 if &ctx.request.path == logout_path {
388 let cookie_value = ctx
389 .request
390 .headers
391 .get("cookie")
392 .and_then(|v| v.first())
393 .and_then(|h| read_cookie(h, &self.cookie_name))
394 .map(str::to_string);
395 let del = match server_session::destroy(
396 &self.backend,
397 cookie_value.as_deref(),
398 &self.cookie_name,
399 &self.cookie_path,
400 )
401 .await
402 {
403 Ok(c) => c,
404 Err(e) => return Err(Self::store_error(ctx, e)),
405 };
406 return self.redirect(ctx, "/".to_string(), vec![del]);
407 }
408 }
409
410 match self.read_session(&ctx).await {
412 Ok(Some(user)) => {
413 self.attach_user(&mut ctx, &user);
414 return Ok(PluginOutput::success(ctx));
415 }
416 Ok(None) => {}
417 Err(e) => return Err(Self::store_error(ctx, e)),
418 }
419
420 if let Some(ticket) = extract_ticket(&ctx.request.query_params, &self.ticket_param) {
424 return match self.cas_validate(&ctx, &ticket).await {
425 Ok(user) => {
426 let ttl = Duration::from_secs(self.cookie_lifetime);
427 let meta = server_session::meta_now(&ctx, "cas-auth", &user, ttl);
428 let payload = serde_json::to_vec(&CasSession { user }).unwrap_or_default();
429 let set = match server_session::establish(
430 &self.backend,
431 sealer,
432 &payload,
433 ttl,
434 meta,
435 &self.cookie_name,
436 &self.session_attrs(&ctx),
437 )
438 .await
439 {
440 Ok(s) => s,
441 Err(e) => return Err(Self::store_error(ctx, e)),
442 };
443 let target = self.service_url(&ctx);
444 self.redirect(ctx, target, vec![set])
445 }
446 Err(CasError::Denied(reason)) => self.reject(ctx, &reason),
447 Err(CasError::Infra(reason)) => Err(self.infra_error(ctx, reason)),
448 };
449 }
450
451 let service = self.service_url(&ctx);
453 let login = build_login_url(&self.idp_uri, &service);
454 self.redirect(ctx, login, vec![])
455 }
456
457 async fn execute_stateless(&self, mut ctx: Context) -> PluginResult {
459 let ticket = match extract_ticket(&ctx.request.query_params, &self.ticket_param) {
460 Some(t) => t,
461 None => return self.reject(ctx, "missing CAS ticket"),
462 };
463
464 match self.cas_validate(&ctx, &ticket).await {
465 Ok(user) => {
466 self.attach_user(&mut ctx, &user);
467 Ok(PluginOutput::success(ctx))
468 }
469 Err(CasError::Denied(reason)) => self.reject(ctx, &reason),
470 Err(CasError::Infra(reason)) => Err(self.infra_error(ctx, reason)),
471 }
472 }
473}
474
475fn classify_validation(status: u16, body: &[u8]) -> Result<String, CasError> {
480 if status != 200 {
481 return Err(CasError::Infra(format!(
482 "CAS validation returned non-200 ({})",
483 status
484 )));
485 }
486 parse_service_validate(body).ok_or_else(|| CasError::Denied("invalid ticket".to_string()))
487}
488
489fn session_secret(config: &HashMap<String, serde_json::Value>) -> Option<String> {
491 config
492 .get("session_secret")
493 .and_then(|v| v.as_str())
494 .or_else(|| {
495 config
496 .get("session")
497 .and_then(|s| s.get("secret"))
498 .and_then(|v| v.as_str())
499 })
500 .filter(|s| !s.is_empty())
501 .map(String::from)
502}
503
504fn session_cookie_str(config: &HashMap<String, serde_json::Value>, key: &str) -> Option<String> {
507 config
508 .get("session")
509 .and_then(|s| s.get("cookie"))
510 .and_then(|c| c.get(key))
511 .or_else(|| config.get(&format!("session_cookie_{key}")))
512 .and_then(|v| v.as_str())
513 .filter(|s| !s.is_empty())
514 .map(String::from)
515}
516
517fn session_cookie_u64(config: &HashMap<String, serde_json::Value>, key: &str) -> Option<u64> {
520 config
521 .get("session")
522 .and_then(|s| s.get("cookie"))
523 .and_then(|c| c.get(key))
524 .or_else(|| config.get(&format!("session_cookie_{key}")))
525 .and_then(|v| v.as_u64())
526}
527
528fn percent_encode(value: &str) -> String {
530 let mut out = String::with_capacity(value.len());
531 for b in value.bytes() {
532 match b {
533 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
534 out.push(b as char)
535 }
536 _ => out.push_str(&format!("%{:02X}", b)),
537 }
538 }
539 out
540}
541
542fn extract_ticket(query: &HashMap<String, Vec<String>>, param: &str) -> Option<String> {
544 query
545 .get(param)
546 .and_then(|v| v.first())
547 .filter(|s| !s.is_empty())
548 .cloned()
549}
550
551fn build_validate_url(idp_uri: &str, ticket: &str, service: &str) -> String {
553 format!(
554 "{}/serviceValidate?ticket={}&service={}",
555 idp_uri,
556 percent_encode(ticket),
557 percent_encode(service),
558 )
559}
560
561fn build_login_url(idp_uri: &str, service: &str) -> String {
563 format!("{}/login?service={}", idp_uri, percent_encode(service))
564}
565
566fn parse_service_validate(body: &[u8]) -> Option<String> {
573 let text = std::str::from_utf8(body).ok()?;
574
575 if text.trim_start().starts_with('{') {
577 if let Ok(json) = serde_json::from_str::<serde_json::Value>(text) {
578 let user = json
579 .get("serviceResponse")
580 .and_then(|v| v.get("authenticationSuccess"))
581 .and_then(|v| v.get("user"))
582 .and_then(|v| v.as_str());
583 return user.map(|s| s.trim().to_string());
584 }
585 }
586
587 if !text.contains("authenticationSuccess") {
589 return None;
590 }
591 extract_xml_tag(text, "cas:user").or_else(|| extract_xml_tag(text, "user"))
592}
593
594fn extract_xml_tag(text: &str, tag: &str) -> Option<String> {
596 let open = format!("<{}>", tag);
597 let close = format!("</{}>", tag);
598 let start = text.find(&open)? + open.len();
599 let end = text[start..].find(&close)? + start;
600 let value = text[start..end].trim();
601 if value.is_empty() {
602 None
603 } else {
604 Some(value.to_string())
605 }
606}
607
608#[async_trait]
609impl Plugin for CasAuthPlugin {
610 fn plugin_type(&self) -> &str {
611 "cas-auth"
612 }
613
614 async fn execute(&self, ctx: Context) -> PluginResult {
615 if self.sealer.is_some() {
616 self.execute_interactive(ctx).await
617 } else {
618 self.execute_stateless(ctx).await
619 }
620 }
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626
627 fn plugin() -> CasAuthPlugin {
628 let mut cfg = HashMap::new();
629 cfg.insert(
630 "idp_uri".to_string(),
631 serde_json::json!("https://cas.example.org/cas"),
632 );
633 cfg.insert(
634 "service".to_string(),
635 serde_json::json!("https://app.example.org/"),
636 );
637 CasAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap()
638 }
639
640 fn ctx(path: &str, query: HashMap<String, Vec<String>>) -> Context {
641 crate::context::Context::new(crate::context::GatewayRequest {
642 method: "GET".into(),
643 path: path.into(),
644 host: "app.example.org".into(),
645 scheme: "https".into(),
646 headers: HashMap::new(),
647 query_params: query,
648 body: Bytes::new(),
649 remote_addr: "1.2.3.4:5".into(),
650 protocol: crate::context::Protocol::Http1,
651 })
652 }
653
654 #[test]
655 fn test_from_config_requires_idp_uri() {
656 assert!(CasAuthPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err());
657 let p = plugin();
658 assert_eq!(p.idp_uri, "https://cas.example.org/cas");
659 assert_eq!(p.ticket_param, "ticket");
660 assert!(p.ssl_verify);
661 }
662
663 #[test]
664 fn test_stateless_by_default() {
665 let p = plugin();
667 assert!(p.sealer.is_none());
668 assert_eq!(p.cookie_name, "cas_session");
669 assert_eq!(p.cookie_path, "/");
670 assert_eq!(p.cookie_lifetime, 3600);
671 assert!(p.logout_path.is_none());
672 }
673
674 #[test]
675 fn test_session_cookie_path_configurable() {
676 let mut cfg = HashMap::new();
678 cfg.insert("idp_uri".to_string(), serde_json::json!("https://cas/cas"));
679 cfg.insert(
680 "session".to_string(),
681 serde_json::json!({ "secret": "abc", "cookie": { "path": "/app_a" } }),
682 );
683 let p = CasAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
684 assert_eq!(p.cookie_path, "/app_a");
685
686 let mut cfg = HashMap::new();
688 cfg.insert("idp_uri".to_string(), serde_json::json!("https://cas/cas"));
689 cfg.insert("session_secret".to_string(), serde_json::json!("abc"));
690 cfg.insert(
691 "session_cookie_path".to_string(),
692 serde_json::json!("/app_b"),
693 );
694 let p = CasAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
695 assert_eq!(p.cookie_path, "/app_b");
696 }
697
698 #[test]
699 fn test_interactive_enabled_by_session_secret() {
700 let mut cfg = HashMap::new();
702 cfg.insert("idp_uri".to_string(), serde_json::json!("https://cas/cas"));
703 cfg.insert("session_secret".to_string(), serde_json::json!("s3cr3t"));
704 let p = CasAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
705 assert!(p.sealer.is_some());
706
707 let mut cfg = HashMap::new();
709 cfg.insert("idp_uri".to_string(), serde_json::json!("https://cas/cas"));
710 cfg.insert(
711 "session".to_string(),
712 serde_json::json!({ "secret": "abc", "cookie": { "name": "sess", "lifetime": 60 } }),
713 );
714 cfg.insert("logout_path".to_string(), serde_json::json!("/logout"));
715 let p = CasAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
716 assert!(p.sealer.is_some());
717 assert_eq!(p.cookie_name, "sess");
718 assert_eq!(p.cookie_lifetime, 60);
719 assert_eq!(p.logout_path.as_deref(), Some("/logout"));
720 }
721
722 #[test]
723 fn test_extract_ticket() {
724 let mut query = HashMap::new();
725 query.insert("ticket".to_string(), vec!["ST-12345".to_string()]);
726 assert_eq!(
727 extract_ticket(&query, "ticket"),
728 Some("ST-12345".to_string())
729 );
730 assert_eq!(extract_ticket(&query, "other"), None);
731 let mut query = HashMap::new();
733 query.insert("ticket".to_string(), vec!["".to_string()]);
734 assert_eq!(extract_ticket(&query, "ticket"), None);
735 assert_eq!(extract_ticket(&HashMap::new(), "ticket"), None);
736 }
737
738 #[test]
739 fn test_build_validate_url() {
740 assert_eq!(
741 build_validate_url("https://cas.example.org/cas", "ST-1 2", "https://app/"),
742 "https://cas.example.org/cas/serviceValidate?ticket=ST-1%202&service=https%3A%2F%2Fapp%2F"
743 );
744 }
745
746 #[test]
747 fn test_build_login_url() {
748 assert_eq!(
749 build_login_url(
750 "https://cas.example.org/cas",
751 "https://app.example.org/dashboard"
752 ),
753 "https://cas.example.org/cas/login?service=https%3A%2F%2Fapp.example.org%2Fdashboard"
754 );
755 }
756
757 #[test]
758 fn test_session_seal_open_round_trip() {
759 let sealer = CookieSealer::new("cas-secret");
760 let payload = serde_json::to_vec(&CasSession {
761 user: "alice".into(),
762 })
763 .unwrap();
764 let cookie = sealer.seal(&payload, Duration::from_secs(3600));
765 let opened = sealer.open(&cookie).unwrap();
766 let session: CasSession = serde_json::from_slice(&opened).unwrap();
767 assert_eq!(session.user, "alice");
768 }
769
770 #[test]
771 fn test_parse_service_validate_xml_success() {
772 let body = br#"<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
773 <cas:authenticationSuccess>
774 <cas:user>alice</cas:user>
775 </cas:authenticationSuccess>
776</cas:serviceResponse>"#;
777 assert_eq!(parse_service_validate(body), Some("alice".to_string()));
778
779 let body = b"<serviceResponse><authenticationSuccess><user>bob</user></authenticationSuccess></serviceResponse>";
781 assert_eq!(parse_service_validate(body), Some("bob".to_string()));
782 }
783
784 #[test]
785 fn test_parse_service_validate_xml_failure() {
786 let body = br#"<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
787 <cas:authenticationFailure code='INVALID_TICKET'>ticket not recognized</cas:authenticationFailure>
788</cas:serviceResponse>"#;
789 assert_eq!(parse_service_validate(body), None);
790 }
791
792 #[test]
793 fn test_parse_service_validate_json() {
794 let body = br#"{"serviceResponse":{"authenticationSuccess":{"user":"carol"}}}"#;
795 assert_eq!(parse_service_validate(body), Some("carol".to_string()));
796
797 let body = br#"{"serviceResponse":{"authenticationFailure":{"code":"INVALID_TICKET"}}}"#;
798 assert_eq!(parse_service_validate(body), None);
799 }
800
801 #[tokio::test]
802 async fn test_missing_ticket_rejected() {
803 let p = plugin();
804 let out = p.execute(ctx("/", HashMap::new())).await.unwrap();
805 assert_eq!(out.port, Some("denied"));
806 assert_eq!(out.context.response.status_code, 401);
807 }
808
809 #[test]
811 fn test_classify_validation_invalid_ticket_is_denied() {
812 let failure = br#"<cas:serviceResponse><cas:authenticationFailure code='INVALID_TICKET'/></cas:serviceResponse>"#;
813 match classify_validation(200, failure) {
814 Err(CasError::Denied(m)) => assert!(m.contains("invalid ticket"), "{m}"),
815 other => panic!("expected Denied, got {other:?}"),
816 }
817 match classify_validation(200, b"garbage") {
819 Err(CasError::Denied(_)) => {}
820 other => panic!("expected Denied, got {other:?}"),
821 }
822 let ok = b"<serviceResponse><authenticationSuccess><user>eve</user></authenticationSuccess></serviceResponse>";
824 assert_eq!(classify_validation(200, ok).unwrap(), "eve");
825 }
826
827 #[test]
830 fn test_classify_validation_non_200_is_infra() {
831 for status in [500u16, 502, 404, 401] {
832 match classify_validation(status, b"") {
833 Err(CasError::Infra(m)) => assert!(m.contains("non-200"), "{m}"),
834 other => panic!("expected Infra for {status}, got {other:?}"),
835 }
836 }
837 }
838
839 #[tokio::test]
842 async fn test_transport_failure_is_error_port_not_denied() {
843 let mut cfg = HashMap::new();
844 cfg.insert(
845 "idp_uri".to_string(),
846 serde_json::json!("http://127.0.0.1:1"),
847 );
848 cfg.insert(
849 "service".to_string(),
850 serde_json::json!("https://app.example.org/"),
851 );
852 cfg.insert("timeout_ms".to_string(), serde_json::json!(500));
853 let p = CasAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
854
855 let mut query = HashMap::new();
856 query.insert("ticket".to_string(), vec!["ST-1".to_string()]);
857 let err = p
858 .execute(ctx("/", query))
859 .await
860 .expect_err("transport failure must be an Err on the error port");
861 crate::plugins::util::provider_error::testing::assert_provider_error(
863 &err,
864 "CAS_AUTH_PROVIDER_ERROR",
865 );
866 }
867
868 #[test]
869 fn test_service_url_derived_from_request() {
870 let mut cfg = HashMap::new();
871 cfg.insert("idp_uri".to_string(), serde_json::json!("https://cas/cas"));
872 let p = CasAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
873 assert_eq!(
874 p.service_url(&ctx("/dashboard", HashMap::new())),
875 "https://app.example.org/dashboard"
876 );
877 }
878
879 #[tokio::test]
880 async fn test_interactive_begin_login_redirects() {
881 let mut cfg = HashMap::new();
883 cfg.insert(
884 "idp_uri".to_string(),
885 serde_json::json!("https://cas.example.org/cas"),
886 );
887 cfg.insert("session_secret".to_string(), serde_json::json!("s3cr3t"));
888 let p = CasAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
889
890 let out = p.execute(ctx("/dashboard", HashMap::new())).await.unwrap();
891 assert_eq!(out.port, Some("redirect"));
892 assert_eq!(out.context.response.status_code, 302);
893 let location = &out.context.response.headers.get("location").unwrap()[0];
894 assert!(
895 location.starts_with("https://cas.example.org/cas/login?service="),
896 "{location}"
897 );
898 assert!(!out.context.response.headers.contains_key("set-cookie"));
900 }
901
902 #[tokio::test]
903 async fn test_interactive_valid_session_passes() {
904 let mut cfg = HashMap::new();
905 cfg.insert(
906 "idp_uri".to_string(),
907 serde_json::json!("https://cas.example.org/cas"),
908 );
909 cfg.insert("session_secret".to_string(), serde_json::json!("s3cr3t"));
910 let p = CasAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
911
912 let sealer = CookieSealer::new("s3cr3t");
914 let payload = serde_json::to_vec(&CasSession {
915 user: "dave".into(),
916 })
917 .unwrap();
918 let sealed = sealer.seal(&payload, Duration::from_secs(3600));
919
920 let mut c = ctx("/dashboard", HashMap::new());
921 c.request.headers.insert(
922 "cookie".to_string(),
923 vec![format!("cas_session={}", sealed)],
924 );
925
926 let out = p.execute(c).await.unwrap();
927 assert_eq!(
928 out.context.request.headers.get("x-cas-user").unwrap()[0],
929 "dave"
930 );
931 assert_eq!(out.context.message.get("user").unwrap(), "dave");
932 }
933
934 #[tokio::test]
935 async fn test_interactive_logout_clears_cookie() {
936 let mut cfg = HashMap::new();
937 cfg.insert(
938 "idp_uri".to_string(),
939 serde_json::json!("https://cas.example.org/cas"),
940 );
941 cfg.insert("session_secret".to_string(), serde_json::json!("s3cr3t"));
942 cfg.insert("logout_path".to_string(), serde_json::json!("/logout"));
943 let p = CasAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
944
945 let out = p.execute(ctx("/logout", HashMap::new())).await.unwrap();
946 assert_eq!(out.port, Some("redirect"));
947 assert_eq!(out.context.response.status_code, 302);
948 assert_eq!(
949 out.context.response.headers.get("location").unwrap()[0],
950 "/"
951 );
952 let set = &out.context.response.headers.get("set-cookie").unwrap()[0];
953 assert!(
954 set.contains("cas_session=") && set.contains("Max-Age=0"),
955 "{set}"
956 );
957 }
958
959 #[cfg(feature = "redis-store")]
960 fn resources_with_fake_store() -> (Arc<PluginResources>, Arc<crate::sessions::FakeSessionStore>)
961 {
962 let fake = Arc::new(crate::sessions::FakeSessionStore::default());
963 let resources = PluginResources::empty();
964 resources.stores.store(Arc::new(
965 crate::stores::StoreRegistry::with_fake_session_store("s1", fake.clone()),
966 ));
967 (resources, fake)
968 }
969
970 #[test]
972 fn test_session_storage_redis_requires_store() {
973 let mut cfg = HashMap::new();
974 cfg.insert(
975 "idp_uri".to_string(),
976 serde_json::json!("https://cas.example.org/cas"),
977 );
978 cfg.insert("session_secret".to_string(), serde_json::json!("s3cr3t"));
979 cfg.insert(
980 "session".to_string(),
981 serde_json::json!({ "storage": "redis" }),
982 );
983 let err = CasAuthPlugin::from_config(&cfg, &PluginResources::empty())
985 .err()
986 .unwrap();
987 assert!(err.contains("requires 'session.store'"), "{err}");
988 }
989
990 #[cfg(feature = "redis-store")]
993 #[tokio::test]
994 async fn test_redis_session_read_and_store_outage_503() {
995 use crate::sessions::SessionStore as _;
996
997 let (resources, fake) = resources_with_fake_store();
998 let mut cfg = HashMap::new();
999 cfg.insert(
1000 "idp_uri".to_string(),
1001 serde_json::json!("https://cas.example.org/cas"),
1002 );
1003 cfg.insert("session_secret".to_string(), serde_json::json!("s3cr3t"));
1004 cfg.insert(
1005 "session".to_string(),
1006 serde_json::json!({ "storage": "redis", "store": "s1" }),
1007 );
1008 let p = CasAuthPlugin::from_config(&cfg, &resources).unwrap();
1009
1010 let sealer = CookieSealer::new("s3cr3t");
1012 let payload = serde_json::to_vec(&CasSession {
1013 user: "alice".into(),
1014 })
1015 .unwrap();
1016 let sealed = sealer.seal(&payload, Duration::from_secs(3600));
1017 let id = crate::sessions::SessionId::random();
1018 let meta = crate::sessions::SessionMeta {
1019 id: String::new(),
1020 subject: "alice".to_string(),
1021 plugin: "cas-auth".to_string(),
1022 policy: String::new(),
1023 route: String::new(),
1024 created_at: 0,
1025 expires_at: 0,
1026 };
1027 fake.put(&id, sealed.as_bytes(), Duration::from_secs(3600), &meta)
1028 .await
1029 .unwrap();
1030
1031 let mut c = ctx("/dashboard", HashMap::new());
1032 c.request.headers.insert(
1033 "cookie".to_string(),
1034 vec![format!("cas_session={}", id.as_str())],
1035 );
1036 let out = p.execute(c).await.unwrap();
1037 assert_eq!(
1038 out.context.request.headers.get("x-cas-user").unwrap()[0],
1039 "alice"
1040 );
1041 assert_eq!(out.context.message.get("user").unwrap(), "alice");
1042
1043 fake.fail.store(true, std::sync::atomic::Ordering::Relaxed);
1045 let mut c = ctx("/dashboard", HashMap::new());
1046 c.request.headers.insert(
1047 "cookie".to_string(),
1048 vec![format!("cas_session={}", id.as_str())],
1049 );
1050 let err = p.execute(c).await.unwrap_err();
1051 assert_eq!(err.error.code, "SESSION_STORE_ERROR");
1052 assert_eq!(err.context.response.status_code, 503);
1053 }
1054}