Skip to main content

featherbit/plugins/native/
cas_auth.rs

1//! CAS authentication plugin (`cas-auth`).
2//!
3//! Port of the ticket-validation step of APISIX's `cas-auth` plugin, with an
4//! optional **interactive CAS SSO login flow** layered on top via the shared
5//! [encrypted-cookie session primitive](crate::plugins::util::cookie_session).
6//!
7//! ## Two modes
8//!
9//! - **Stateless (default)** — when no session secret is configured the node
10//!   behaves exactly as before: a request carrying a CAS service `ticket`
11//!   query parameter is validated against the CAS server's `/serviceValidate`
12//!   endpoint and, on success, the authenticated user is attached to the
13//!   request; anything else is rejected with `CAS_AUTH_FAILED` (`401`).
14//! - **Interactive (opt-in)** — set `session.secret` (or `session_secret`) to
15//!   turn on the full browser login flow. The authenticated user is sealed
16//!   into an encrypted client-side cookie (no server-side session store), so
17//!   the node can redirect unauthenticated browsers to the IdP's `/login`,
18//!   consume the returned ticket at the callback, and thereafter authenticate
19//!   requests straight from the cookie. See the three-branch logic in
20//!   [`CasAuthPlugin::execute_interactive`].
21//!
22//! ## Redirect wiring (interactive mode)
23//!
24//! A `302` produced by this node (login redirect, post-callback redirect, or
25//! logout) is returned as an [`Err`] carrying the prepared response with code
26//! `CAS_REDIRECT`, following the same early-exit convention as the
27//! `fault-injection`/`mocking` nodes. **Wire the node's `error` edge to
28//! `client.in`** so the redirect reaches the browser; the `success` edge
29//! carries authenticated requests on to the upstream.
30
31use 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/// Session payload sealed into the CAS session cookie (interactive mode).
47#[derive(Debug, Clone, Serialize, Deserialize)]
48struct CasSession {
49    /// The CAS-authenticated username captured at login.
50    user: String,
51}
52
53/// Validates CAS service tickets and, in interactive mode, runs the SSO flow.
54pub struct CasAuthPlugin {
55    /// CAS server base URI (e.g. `https://cas.example.org/cas`).
56    idp_uri: String,
57    /// Service URL sent to `/serviceValidate`; when unset it is derived from the
58    /// request (`scheme://host/path`). Must match the service the ticket was
59    /// issued for, so an explicit value is strongly recommended.
60    service: Option<String>,
61    /// Query parameter the ticket is read from (default `ticket`).
62    ticket_param: String,
63    /// Whether the CAS server's TLS certificate is verified.
64    ssl_verify: bool,
65    /// Whole-call deadline for the validation callout.
66    timeout: Duration,
67    /// When set, interactive SSO login is enabled and this seals/opens the
68    /// session cookie. `None` keeps the stateless ticket-validator behavior.
69    sealer: Option<CookieSealer>,
70    /// Name of the session cookie (interactive mode).
71    cookie_name: String,
72    /// `Path` attribute of the session cookie (interactive mode). Scope it to a
73    /// subpath (e.g. `/app_a`) so nodes on distinct subpaths keep independent
74    /// sessions. Defaults to `/`.
75    cookie_path: String,
76    /// Session cookie lifetime in seconds (interactive mode).
77    cookie_lifetime: u64,
78    /// Optional logout path; a request to it clears the session cookie.
79    logout_path: Option<String>,
80    client: Arc<OutboundClient>,
81}
82
83impl CasAuthPlugin {
84    /// Builds the plugin from node config.
85    ///
86    /// Accepted keys:
87    /// - `idp_uri` (string, **required**): CAS server base URI. The validation
88    ///   request goes to `<idp_uri>/serviceValidate`.
89    /// - `service` (string, optional): service URL passed to `/serviceValidate`.
90    ///   When omitted it is derived from the request scheme/host/path; because
91    ///   CAS requires the validated service to match the login service, set this
92    ///   explicitly whenever the gateway sits behind a proxy.
93    /// - `ticket_param` (string, default `"ticket"`): query parameter carrying
94    ///   the CAS service ticket.
95    /// - `ssl_verify` (bool, default `true`): verify the CAS server TLS cert.
96    /// - `timeout_ms` (u64, default `3000`): callout deadline.
97    ///
98    /// Interactive-mode keys (present ⇒ interactive login is enabled):
99    /// - `session_secret` (string) or `session.secret` (string): signing/encryption
100    ///   secret for the session cookie. Setting it turns on the SSO flow.
101    /// - `session.cookie.name` (string, default `"cas_session"`): session cookie name.
102    /// - `session.cookie.path` (string, default `"/"`): session cookie `Path`;
103    ///   scope to a subpath (e.g. `/app_a`) for independent per-app sessions.
104    /// - `session.cookie.lifetime` (u64 seconds, default `3600`): cookie lifetime.
105    /// - `logout_path` (string, optional): request path that clears the session
106    ///   cookie and redirects to `/`.
107    ///
108    /// ```yaml
109    /// type: cas-auth
110    /// config:
111    ///   idp_uri: https://cas.example.org/cas
112    ///   service: https://app.example.org/
113    ///   ssl_verify: true
114    ///   session:
115    ///     secret: ${CAS_SESSION_SECRET}
116    ///     cookie: { name: cas_session, lifetime: 3600 }
117    /// ```
118    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        // Interactive mode is enabled when a session secret is configured.
155        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    /// Builds a 401 rejection routed through the node's error port.
182    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    /// Builds a `302` early-exit carrying the prepared response. Wire the
205    /// node's **error** edge to `client.in` so this reaches the browser.
206    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    /// The service URL sent to `/serviceValidate`: the configured value, or one
234    /// derived from the request (`scheme://host/path`, without the query so the
235    /// ticket is dropped).
236    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    /// Cookie attributes for the session cookie: `HttpOnly`, `SameSite=Lax`, and
247    /// `Secure` only over HTTPS (so plain-HTTP dev works).
248    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    /// Attaches the authenticated user to the request/context.
259    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    /// Reads and opens the session cookie, returning the authenticated user.
274    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    /// Validates a CAS ticket against `/serviceValidate`, returning the user.
284    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    /// Interactive SSO flow: session cookie → callback → begin login.
311    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        // 0. Logout: clear the session cookie and bounce to "/".
318        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        // 1. Valid session cookie → authenticate straight from it.
326        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        // 2. Callback: a CAS ticket came back on the service URL. Validate it,
335        //    seal a session cookie, and redirect to the ticket-free service URL.
336        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        // 3. No session, not a callback → begin login at the IdP.
351        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    /// Stateless ticket validation (the default, pre-interactive behavior).
357    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
376/// Reads the session secret from `session_secret` or nested `session.secret`.
377fn 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
391/// Reads a string field from nested `session.cookie.<key>`, falling back to the
392/// flat `session_cookie_<key>` form (used by the UI schema).
393fn 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
404/// Reads a u64 field from nested `session.cookie.<key>`, falling back to the
405/// flat `session_cookie_<key>` form (used by the UI schema).
406fn 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
415/// Percent-encodes a query-argument value (RFC3986 unreserved chars kept).
416fn 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
429/// Reads the CAS ticket from the request's query parameters.
430fn 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
438/// Builds the CAS `/serviceValidate` URL.
439fn 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
448/// Builds the CAS `/login?service=<service>` URL used to begin interactive login.
449fn build_login_url(idp_uri: &str, service: &str) -> String {
450    format!("{}/login?service={}", idp_uri, percent_encode(service))
451}
452
453/// Extracts the authenticated username from a CAS `/serviceValidate` response.
454///
455/// Handles both the default CAS 2.0 **XML** (`<cas:authenticationSuccess>` /
456/// `<cas:user>`, with or without the `cas:` prefix) and the CAS 3.0 **JSON**
457/// (`serviceResponse.authenticationSuccess.user`) formats. Returns `None` for
458/// an authentication-failure response or anything unparseable.
459fn parse_service_validate(body: &[u8]) -> Option<String> {
460    let text = std::str::from_utf8(body).ok()?;
461
462    // JSON format (format=json / CAS 3.0).
463    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    // XML format (default CAS 2.0).
475    if !text.contains("authenticationSuccess") {
476        return None;
477    }
478    extract_xml_tag(text, "cas:user").or_else(|| extract_xml_tag(text, "user"))
479}
480
481/// Returns the trimmed text between the first `<tag>` and its `</tag>`.
482fn 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        // No session secret ⇒ stateless (sealer absent), defaults populated.
557        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        // Nested form.
568        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        // Flat form the Web UI emits.
578        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        // top-level session_secret
592        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        // nested session.secret + cookie overrides
599        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        // empty ticket ignored
623        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        // no cas: prefix
671        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        // Interactive mode, no cookie, no ticket → 302 to the IdP /login.
717        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        // No cookie is set when merely beginning login.
737        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        // Seal a session cookie the way a successful callback would.
751        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}