1use async_trait::async_trait;
32use bytes::Bytes;
33use serde::{Deserialize, Serialize};
34use std::collections::HashMap;
35use std::sync::Arc;
36use std::time::Duration;
37
38use crate::context::{Context, GatewayError};
39use crate::outbound::{OutboundClient, OutboundRequest};
40use crate::plugins::resources::PluginResources;
41use crate::plugins::util::cookie_session::{
42 build_set_cookie, delete_cookie, read_cookie, CookieAttrs, CookieSealer, SameSite,
43};
44use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48struct CasSession {
49 user: String,
51}
52
53pub struct CasAuthPlugin {
55 idp_uri: String,
57 service: Option<String>,
61 ticket_param: String,
63 ssl_verify: bool,
65 timeout: Duration,
67 sealer: Option<CookieSealer>,
70 cookie_name: String,
72 cookie_path: String,
76 cookie_lifetime: u64,
78 logout_path: Option<String>,
80 client: Arc<OutboundClient>,
81}
82
83impl CasAuthPlugin {
84 pub fn from_config(
119 config: &HashMap<String, serde_json::Value>,
120 resources: &Arc<PluginResources>,
121 ) -> Result<Self, String> {
122 let idp_uri = config
123 .get("idp_uri")
124 .and_then(|v| v.as_str())
125 .filter(|s| !s.trim().is_empty())
126 .ok_or("cas-auth plugin requires a non-empty 'idp_uri'")?
127 .trim_end_matches('/')
128 .to_string();
129
130 let service = config
131 .get("service")
132 .and_then(|v| v.as_str())
133 .filter(|s| !s.trim().is_empty())
134 .map(String::from);
135
136 let ticket_param = config
137 .get("ticket_param")
138 .and_then(|v| v.as_str())
139 .unwrap_or("ticket")
140 .to_string();
141
142 let ssl_verify = config
143 .get("ssl_verify")
144 .and_then(|v| v.as_bool())
145 .unwrap_or(true);
146
147 let timeout = Duration::from_millis(
148 config
149 .get("timeout_ms")
150 .and_then(|v| v.as_u64())
151 .unwrap_or(3_000),
152 );
153
154 let sealer = session_secret(config).map(|s| CookieSealer::new(&s));
156 let cookie_name =
157 session_cookie_str(config, "name").unwrap_or_else(|| "cas_session".to_string());
158 let cookie_path = session_cookie_str(config, "path").unwrap_or_else(|| "/".to_string());
159 let cookie_lifetime = session_cookie_u64(config, "lifetime").unwrap_or(3_600);
160 let logout_path = config
161 .get("logout_path")
162 .and_then(|v| v.as_str())
163 .filter(|s| !s.is_empty())
164 .map(String::from);
165
166 Ok(Self {
167 idp_uri,
168 service,
169 ticket_param,
170 ssl_verify,
171 timeout,
172 sealer,
173 cookie_name,
174 cookie_path,
175 cookie_lifetime,
176 logout_path,
177 client: resources.outbound.clone(),
178 })
179 }
180
181 fn reject(&self, ctx: Context, message: &str) -> PluginResult {
183 let mut ctx = ctx;
184 ctx.response.status_code = 401;
185 ctx.response.body = Bytes::from(format!(
186 r#"{{"error": "unauthorized", "message": "{}"}}"#,
187 message
188 ));
189 ctx.response.headers.insert(
190 "content-type".to_string(),
191 vec!["application/json".to_string()],
192 );
193 Err(PluginExecutionError {
194 context: ctx,
195 error: GatewayError {
196 node_id: String::new(),
197 code: "CAS_AUTH_FAILED".to_string(),
198 message: message.to_string(),
199 metadata: HashMap::new(),
200 },
201 })
202 }
203
204 fn redirect(
207 &self,
208 mut ctx: Context,
209 location: String,
210 set_cookies: Vec<String>,
211 ) -> PluginResult {
212 ctx.response.status_code = 302;
213 ctx.response
214 .headers
215 .insert("location".to_string(), vec![location]);
216 if !set_cookies.is_empty() {
217 ctx.response
218 .headers
219 .insert("set-cookie".to_string(), set_cookies);
220 }
221 ctx.response.body = Bytes::new();
222 Err(PluginExecutionError {
223 context: ctx,
224 error: GatewayError {
225 node_id: String::new(),
226 code: "CAS_REDIRECT".to_string(),
227 message: "cas-auth redirect".to_string(),
228 metadata: HashMap::new(),
229 },
230 })
231 }
232
233 fn service_url(&self, ctx: &Context) -> String {
237 if let Some(ref service) = self.service {
238 return service.clone();
239 }
240 format!(
241 "{}://{}{}",
242 ctx.request.scheme, ctx.request.host, ctx.request.path
243 )
244 }
245
246 fn session_attrs(&self, ctx: &Context) -> CookieAttrs<'_> {
249 CookieAttrs {
250 path: &self.cookie_path,
251 max_age: Some(self.cookie_lifetime),
252 http_only: true,
253 secure: ctx.request.scheme == "https",
254 same_site: SameSite::Lax,
255 }
256 }
257
258 fn attach_user(&self, ctx: &mut Context, user: &str) {
260 ctx.request
261 .headers
262 .insert("x-cas-user".to_string(), vec![user.to_string()]);
263 ctx.message.insert(
264 "user".to_string(),
265 serde_json::Value::String(user.to_string()),
266 );
267 ctx.message.insert(
268 "user_id".to_string(),
269 serde_json::Value::String(user.to_string()),
270 );
271 }
272
273 fn read_session(&self, ctx: &Context) -> Option<String> {
275 let sealer = self.sealer.as_ref()?;
276 let cookie_header = ctx.request.headers.get("cookie").and_then(|v| v.first())?;
277 let raw = read_cookie(cookie_header, &self.cookie_name)?;
278 let payload = sealer.open(raw).ok()?;
279 let session: CasSession = serde_json::from_slice(&payload).ok()?;
280 Some(session.user)
281 }
282
283 async fn cas_validate(&self, ctx: &Context, ticket: &str) -> Result<String, String> {
285 let service = self.service_url(ctx);
286 let url = build_validate_url(&self.idp_uri, ticket, &service);
287
288 let outbound = OutboundRequest {
289 method: http::Method::GET,
290 url,
291 headers: Vec::new(),
292 body: Bytes::new(),
293 timeout: self.timeout,
294 ssl_verify: self.ssl_verify,
295 tls: None,
296 };
297
298 let response = self
299 .client
300 .request(outbound)
301 .await
302 .map_err(|e| format!("CAS validation request failed: {}", e))?;
303
304 if response.status != 200 {
305 return Err("CAS validation returned non-200".to_string());
306 }
307 parse_service_validate(&response.body).ok_or_else(|| "invalid ticket".to_string())
308 }
309
310 async fn execute_interactive(&self, mut ctx: Context) -> PluginResult {
312 let sealer = self
313 .sealer
314 .as_ref()
315 .expect("execute_interactive only called when a sealer is configured");
316
317 if let Some(ref logout_path) = self.logout_path {
319 if &ctx.request.path == logout_path {
320 let del = delete_cookie(&self.cookie_name, &self.cookie_path);
321 return self.redirect(ctx, "/".to_string(), vec![del]);
322 }
323 }
324
325 if let Some(user) = self.read_session(&ctx) {
327 self.attach_user(&mut ctx, &user);
328 return Ok(PluginOutput {
329 context: ctx,
330 named_outputs: HashMap::new(),
331 });
332 }
333
334 if let Some(ticket) = extract_ticket(&ctx.request.query_params, &self.ticket_param) {
337 return match self.cas_validate(&ctx, &ticket).await {
338 Ok(user) => {
339 let payload = serde_json::to_vec(&CasSession { user }).unwrap_or_default();
340 let sealed = sealer.seal(&payload, Duration::from_secs(self.cookie_lifetime));
341 let set =
342 build_set_cookie(&self.cookie_name, &sealed, &self.session_attrs(&ctx));
343 let target = self.service_url(&ctx);
344 self.redirect(ctx, target, vec![set])
345 }
346 Err(reason) => self.reject(ctx, &reason),
347 };
348 }
349
350 let service = self.service_url(&ctx);
352 let login = build_login_url(&self.idp_uri, &service);
353 self.redirect(ctx, login, vec![])
354 }
355
356 async fn execute_stateless(&self, mut ctx: Context) -> PluginResult {
358 let ticket = match extract_ticket(&ctx.request.query_params, &self.ticket_param) {
359 Some(t) => t,
360 None => return self.reject(ctx, "missing CAS ticket"),
361 };
362
363 match self.cas_validate(&ctx, &ticket).await {
364 Ok(user) => {
365 self.attach_user(&mut ctx, &user);
366 Ok(PluginOutput {
367 context: ctx,
368 named_outputs: HashMap::new(),
369 })
370 }
371 Err(reason) => self.reject(ctx, &reason),
372 }
373 }
374}
375
376fn session_secret(config: &HashMap<String, serde_json::Value>) -> Option<String> {
378 config
379 .get("session_secret")
380 .and_then(|v| v.as_str())
381 .or_else(|| {
382 config
383 .get("session")
384 .and_then(|s| s.get("secret"))
385 .and_then(|v| v.as_str())
386 })
387 .filter(|s| !s.is_empty())
388 .map(String::from)
389}
390
391fn session_cookie_str(config: &HashMap<String, serde_json::Value>, key: &str) -> Option<String> {
394 config
395 .get("session")
396 .and_then(|s| s.get("cookie"))
397 .and_then(|c| c.get(key))
398 .or_else(|| config.get(&format!("session_cookie_{key}")))
399 .and_then(|v| v.as_str())
400 .filter(|s| !s.is_empty())
401 .map(String::from)
402}
403
404fn session_cookie_u64(config: &HashMap<String, serde_json::Value>, key: &str) -> Option<u64> {
407 config
408 .get("session")
409 .and_then(|s| s.get("cookie"))
410 .and_then(|c| c.get(key))
411 .or_else(|| config.get(&format!("session_cookie_{key}")))
412 .and_then(|v| v.as_u64())
413}
414
415fn percent_encode(value: &str) -> String {
417 let mut out = String::with_capacity(value.len());
418 for b in value.bytes() {
419 match b {
420 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
421 out.push(b as char)
422 }
423 _ => out.push_str(&format!("%{:02X}", b)),
424 }
425 }
426 out
427}
428
429fn extract_ticket(query: &HashMap<String, Vec<String>>, param: &str) -> Option<String> {
431 query
432 .get(param)
433 .and_then(|v| v.first())
434 .filter(|s| !s.is_empty())
435 .cloned()
436}
437
438fn build_validate_url(idp_uri: &str, ticket: &str, service: &str) -> String {
440 format!(
441 "{}/serviceValidate?ticket={}&service={}",
442 idp_uri,
443 percent_encode(ticket),
444 percent_encode(service),
445 )
446}
447
448fn build_login_url(idp_uri: &str, service: &str) -> String {
450 format!("{}/login?service={}", idp_uri, percent_encode(service))
451}
452
453fn parse_service_validate(body: &[u8]) -> Option<String> {
460 let text = std::str::from_utf8(body).ok()?;
461
462 if text.trim_start().starts_with('{') {
464 if let Ok(json) = serde_json::from_str::<serde_json::Value>(text) {
465 let user = json
466 .get("serviceResponse")
467 .and_then(|v| v.get("authenticationSuccess"))
468 .and_then(|v| v.get("user"))
469 .and_then(|v| v.as_str());
470 return user.map(|s| s.trim().to_string());
471 }
472 }
473
474 if !text.contains("authenticationSuccess") {
476 return None;
477 }
478 extract_xml_tag(text, "cas:user").or_else(|| extract_xml_tag(text, "user"))
479}
480
481fn extract_xml_tag(text: &str, tag: &str) -> Option<String> {
483 let open = format!("<{}>", tag);
484 let close = format!("</{}>", tag);
485 let start = text.find(&open)? + open.len();
486 let end = text[start..].find(&close)? + start;
487 let value = text[start..end].trim();
488 if value.is_empty() {
489 None
490 } else {
491 Some(value.to_string())
492 }
493}
494
495#[async_trait]
496impl Plugin for CasAuthPlugin {
497 fn plugin_type(&self) -> &str {
498 "cas-auth"
499 }
500
501 async fn execute(
502 &self,
503 ctx: Context,
504 _named_inputs: &HashMap<String, serde_json::Value>,
505 ) -> PluginResult {
506 if self.sealer.is_some() {
507 self.execute_interactive(ctx).await
508 } else {
509 self.execute_stateless(ctx).await
510 }
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 fn plugin() -> CasAuthPlugin {
519 let mut cfg = HashMap::new();
520 cfg.insert(
521 "idp_uri".to_string(),
522 serde_json::json!("https://cas.example.org/cas"),
523 );
524 cfg.insert(
525 "service".to_string(),
526 serde_json::json!("https://app.example.org/"),
527 );
528 CasAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap()
529 }
530
531 fn ctx(path: &str, query: HashMap<String, Vec<String>>) -> Context {
532 crate::context::Context::new(crate::context::GatewayRequest {
533 method: "GET".into(),
534 path: path.into(),
535 host: "app.example.org".into(),
536 scheme: "https".into(),
537 headers: HashMap::new(),
538 query_params: query,
539 body: Bytes::new(),
540 remote_addr: "1.2.3.4:5".into(),
541 protocol: crate::context::Protocol::Http1,
542 })
543 }
544
545 #[test]
546 fn test_from_config_requires_idp_uri() {
547 assert!(CasAuthPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err());
548 let p = plugin();
549 assert_eq!(p.idp_uri, "https://cas.example.org/cas");
550 assert_eq!(p.ticket_param, "ticket");
551 assert!(p.ssl_verify);
552 }
553
554 #[test]
555 fn test_stateless_by_default() {
556 let p = plugin();
558 assert!(p.sealer.is_none());
559 assert_eq!(p.cookie_name, "cas_session");
560 assert_eq!(p.cookie_path, "/");
561 assert_eq!(p.cookie_lifetime, 3600);
562 assert!(p.logout_path.is_none());
563 }
564
565 #[test]
566 fn test_session_cookie_path_configurable() {
567 let mut cfg = HashMap::new();
569 cfg.insert("idp_uri".to_string(), serde_json::json!("https://cas/cas"));
570 cfg.insert(
571 "session".to_string(),
572 serde_json::json!({ "secret": "abc", "cookie": { "path": "/app_a" } }),
573 );
574 let p = CasAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
575 assert_eq!(p.cookie_path, "/app_a");
576
577 let mut cfg = HashMap::new();
579 cfg.insert("idp_uri".to_string(), serde_json::json!("https://cas/cas"));
580 cfg.insert("session_secret".to_string(), serde_json::json!("abc"));
581 cfg.insert(
582 "session_cookie_path".to_string(),
583 serde_json::json!("/app_b"),
584 );
585 let p = CasAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
586 assert_eq!(p.cookie_path, "/app_b");
587 }
588
589 #[test]
590 fn test_interactive_enabled_by_session_secret() {
591 let mut cfg = HashMap::new();
593 cfg.insert("idp_uri".to_string(), serde_json::json!("https://cas/cas"));
594 cfg.insert("session_secret".to_string(), serde_json::json!("s3cr3t"));
595 let p = CasAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
596 assert!(p.sealer.is_some());
597
598 let mut cfg = HashMap::new();
600 cfg.insert("idp_uri".to_string(), serde_json::json!("https://cas/cas"));
601 cfg.insert(
602 "session".to_string(),
603 serde_json::json!({ "secret": "abc", "cookie": { "name": "sess", "lifetime": 60 } }),
604 );
605 cfg.insert("logout_path".to_string(), serde_json::json!("/logout"));
606 let p = CasAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
607 assert!(p.sealer.is_some());
608 assert_eq!(p.cookie_name, "sess");
609 assert_eq!(p.cookie_lifetime, 60);
610 assert_eq!(p.logout_path.as_deref(), Some("/logout"));
611 }
612
613 #[test]
614 fn test_extract_ticket() {
615 let mut query = HashMap::new();
616 query.insert("ticket".to_string(), vec!["ST-12345".to_string()]);
617 assert_eq!(
618 extract_ticket(&query, "ticket"),
619 Some("ST-12345".to_string())
620 );
621 assert_eq!(extract_ticket(&query, "other"), None);
622 let mut query = HashMap::new();
624 query.insert("ticket".to_string(), vec!["".to_string()]);
625 assert_eq!(extract_ticket(&query, "ticket"), None);
626 assert_eq!(extract_ticket(&HashMap::new(), "ticket"), None);
627 }
628
629 #[test]
630 fn test_build_validate_url() {
631 assert_eq!(
632 build_validate_url("https://cas.example.org/cas", "ST-1 2", "https://app/"),
633 "https://cas.example.org/cas/serviceValidate?ticket=ST-1%202&service=https%3A%2F%2Fapp%2F"
634 );
635 }
636
637 #[test]
638 fn test_build_login_url() {
639 assert_eq!(
640 build_login_url(
641 "https://cas.example.org/cas",
642 "https://app.example.org/dashboard"
643 ),
644 "https://cas.example.org/cas/login?service=https%3A%2F%2Fapp.example.org%2Fdashboard"
645 );
646 }
647
648 #[test]
649 fn test_session_seal_open_round_trip() {
650 let sealer = CookieSealer::new("cas-secret");
651 let payload = serde_json::to_vec(&CasSession {
652 user: "alice".into(),
653 })
654 .unwrap();
655 let cookie = sealer.seal(&payload, Duration::from_secs(3600));
656 let opened = sealer.open(&cookie).unwrap();
657 let session: CasSession = serde_json::from_slice(&opened).unwrap();
658 assert_eq!(session.user, "alice");
659 }
660
661 #[test]
662 fn test_parse_service_validate_xml_success() {
663 let body = br#"<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
664 <cas:authenticationSuccess>
665 <cas:user>alice</cas:user>
666 </cas:authenticationSuccess>
667</cas:serviceResponse>"#;
668 assert_eq!(parse_service_validate(body), Some("alice".to_string()));
669
670 let body = b"<serviceResponse><authenticationSuccess><user>bob</user></authenticationSuccess></serviceResponse>";
672 assert_eq!(parse_service_validate(body), Some("bob".to_string()));
673 }
674
675 #[test]
676 fn test_parse_service_validate_xml_failure() {
677 let body = br#"<cas:serviceResponse xmlns:cas='http://www.yale.edu/tp/cas'>
678 <cas:authenticationFailure code='INVALID_TICKET'>ticket not recognized</cas:authenticationFailure>
679</cas:serviceResponse>"#;
680 assert_eq!(parse_service_validate(body), None);
681 }
682
683 #[test]
684 fn test_parse_service_validate_json() {
685 let body = br#"{"serviceResponse":{"authenticationSuccess":{"user":"carol"}}}"#;
686 assert_eq!(parse_service_validate(body), Some("carol".to_string()));
687
688 let body = br#"{"serviceResponse":{"authenticationFailure":{"code":"INVALID_TICKET"}}}"#;
689 assert_eq!(parse_service_validate(body), None);
690 }
691
692 #[tokio::test]
693 async fn test_missing_ticket_rejected() {
694 let p = plugin();
695 let err = p
696 .execute(ctx("/", HashMap::new()), &HashMap::new())
697 .await
698 .unwrap_err();
699 assert_eq!(err.error.code, "CAS_AUTH_FAILED");
700 assert_eq!(err.context.response.status_code, 401);
701 }
702
703 #[test]
704 fn test_service_url_derived_from_request() {
705 let mut cfg = HashMap::new();
706 cfg.insert("idp_uri".to_string(), serde_json::json!("https://cas/cas"));
707 let p = CasAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
708 assert_eq!(
709 p.service_url(&ctx("/dashboard", HashMap::new())),
710 "https://app.example.org/dashboard"
711 );
712 }
713
714 #[tokio::test]
715 async fn test_interactive_begin_login_redirects() {
716 let mut cfg = HashMap::new();
718 cfg.insert(
719 "idp_uri".to_string(),
720 serde_json::json!("https://cas.example.org/cas"),
721 );
722 cfg.insert("session_secret".to_string(), serde_json::json!("s3cr3t"));
723 let p = CasAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
724
725 let err = p
726 .execute(ctx("/dashboard", HashMap::new()), &HashMap::new())
727 .await
728 .unwrap_err();
729 assert_eq!(err.error.code, "CAS_REDIRECT");
730 assert_eq!(err.context.response.status_code, 302);
731 let location = &err.context.response.headers.get("location").unwrap()[0];
732 assert!(
733 location.starts_with("https://cas.example.org/cas/login?service="),
734 "{location}"
735 );
736 assert!(!err.context.response.headers.contains_key("set-cookie"));
738 }
739
740 #[tokio::test]
741 async fn test_interactive_valid_session_passes() {
742 let mut cfg = HashMap::new();
743 cfg.insert(
744 "idp_uri".to_string(),
745 serde_json::json!("https://cas.example.org/cas"),
746 );
747 cfg.insert("session_secret".to_string(), serde_json::json!("s3cr3t"));
748 let p = CasAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
749
750 let sealer = CookieSealer::new("s3cr3t");
752 let payload = serde_json::to_vec(&CasSession {
753 user: "dave".into(),
754 })
755 .unwrap();
756 let sealed = sealer.seal(&payload, Duration::from_secs(3600));
757
758 let mut c = ctx("/dashboard", HashMap::new());
759 c.request.headers.insert(
760 "cookie".to_string(),
761 vec![format!("cas_session={}", sealed)],
762 );
763
764 let out = p.execute(c, &HashMap::new()).await.unwrap();
765 assert_eq!(
766 out.context.request.headers.get("x-cas-user").unwrap()[0],
767 "dave"
768 );
769 assert_eq!(out.context.message.get("user").unwrap(), "dave");
770 }
771
772 #[tokio::test]
773 async fn test_interactive_logout_clears_cookie() {
774 let mut cfg = HashMap::new();
775 cfg.insert(
776 "idp_uri".to_string(),
777 serde_json::json!("https://cas.example.org/cas"),
778 );
779 cfg.insert("session_secret".to_string(), serde_json::json!("s3cr3t"));
780 cfg.insert("logout_path".to_string(), serde_json::json!("/logout"));
781 let p = CasAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
782
783 let err = p
784 .execute(ctx("/logout", HashMap::new()), &HashMap::new())
785 .await
786 .unwrap_err();
787 assert_eq!(err.error.code, "CAS_REDIRECT");
788 assert_eq!(err.context.response.status_code, 302);
789 assert_eq!(
790 err.context.response.headers.get("location").unwrap()[0],
791 "/"
792 );
793 let set = &err.context.response.headers.get("set-cookie").unwrap()[0];
794 assert!(
795 set.contains("cas_session=") && set.contains("Max-Age=0"),
796 "{set}"
797 );
798 }
799}