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; a missing ticket, or one CAS itself refuses, is rejected with a
14//!   `401` on the `denied` port.
15//! - **Interactive (opt-in)** — set `session.secret` (or `session_secret`) to
16//!   turn on the full browser login flow. The authenticated user is sealed
17//!   into an encrypted client-side cookie (no server-side session store), so
18//!   the node can redirect unauthenticated browsers to the IdP's `/login`,
19//!   consume the returned ticket at the callback, and thereafter authenticate
20//!   requests straight from the cookie. See the three-branch logic in
21//!   [`CasAuthPlugin::execute_interactive`].
22//!
23//! ## Redirect wiring (interactive mode)
24//!
25//! A `302` produced by this node (login redirect, post-callback redirect, or
26//! logout) exits on the dedicated `redirect` output port, following the same
27//! convention as the standalone `redirect` node. **Wire the node's `redirect`
28//! edge to `client.in`** so the response reaches the browser; deliberate
29//! denials exit on `denied` (also wired to `client.in`, or a custom denial
30//! handler); the `success` edge carries authenticated requests on to the
31//! upstream.
32//!
33//! ## The `error` port is live
34//!
35//! Ticket validation is an outbound callout, so this node has a genuine
36//! failure mode: the CAS server unreachable, timed out, or answering
37//! `/serviceValidate` with a non-200. Those exit on `error` (see
38//! [`CasError`] and [`CasAuthPlugin::infra_error`]) — the node could not
39//! reach a verdict. Only a verdict of "this ticket is not valid" (or no
40//! ticket at all) is a `denied`.
41
42use 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/// Why a CAS ticket validation did not yield an authenticated user.
58///
59/// Mirrors `openid-connect`'s `TokenError` split. `Infra` exits through the
60/// node's `error` port — the node could not do its job (the CAS server was
61/// unreachable, timed out, or answered with a non-200); `Denied` exits through
62/// `denied` — CAS was reached, answered, and said the ticket is not valid,
63/// which is the node doing its job and producing a deliberate rejection.
64#[derive(Debug)]
65enum CasError {
66    Infra(String),
67    Denied(String),
68}
69
70/// Session payload sealed into the CAS session cookie (interactive mode).
71#[derive(Debug, Clone, Serialize, Deserialize)]
72struct CasSession {
73    /// The CAS-authenticated username captured at login.
74    user: String,
75}
76
77/// Validates CAS service tickets and, in interactive mode, runs the SSO flow.
78pub struct CasAuthPlugin {
79    /// CAS server base URI (e.g. `https://cas.example.org/cas`).
80    idp_uri: String,
81    /// Service URL sent to `/serviceValidate`; when unset it is derived from the
82    /// request (`scheme://host/path`). Must match the service the ticket was
83    /// issued for, so an explicit value is strongly recommended.
84    service: Option<String>,
85    /// Query parameter the ticket is read from (default `ticket`).
86    ticket_param: String,
87    /// Whether the CAS server's TLS certificate is verified.
88    ssl_verify: bool,
89    /// Whole-call deadline for the validation callout.
90    timeout: Duration,
91    /// When set, interactive SSO login is enabled and this seals/opens the
92    /// session cookie. `None` keeps the stateless ticket-validator behavior.
93    sealer: Option<CookieSealer>,
94    /// Name of the session cookie (interactive mode).
95    cookie_name: String,
96    /// `Path` attribute of the session cookie (interactive mode). Scope it to a
97    /// subpath (e.g. `/app_a`) so nodes on distinct subpaths keep independent
98    /// sessions. Defaults to `/`.
99    cookie_path: String,
100    /// Session cookie lifetime in seconds (interactive mode).
101    cookie_lifetime: u64,
102    /// Optional logout path; a request to it clears the session cookie.
103    logout_path: Option<String>,
104    /// Where the session payload lives: sealed in the cookie itself, or a
105    /// server-side store keyed by a bare id in the cookie. See
106    /// `session.storage` / `session.store` in [`CasAuthPlugin::from_config`].
107    backend: SessionBackend,
108    client: Arc<OutboundClient>,
109}
110
111impl CasAuthPlugin {
112    /// Builds the plugin from node config.
113    ///
114    /// Accepted keys:
115    /// - `idp_uri` (string, **required**): CAS server base URI. The validation
116    ///   request goes to `<idp_uri>/serviceValidate`.
117    /// - `service` (string, optional): service URL passed to `/serviceValidate`.
118    ///   When omitted it is derived from the request scheme/host/path; because
119    ///   CAS requires the validated service to match the login service, set this
120    ///   explicitly whenever the gateway sits behind a proxy.
121    /// - `ticket_param` (string, default `"ticket"`): query parameter carrying
122    ///   the CAS service ticket.
123    /// - `ssl_verify` (bool, default `true`): verify the CAS server TLS cert.
124    /// - `timeout_ms` (u64, default `3000`): callout deadline.
125    ///
126    /// Interactive-mode keys (present ⇒ interactive login is enabled):
127    /// - `session_secret` (string) or `session.secret` (string): signing/encryption
128    ///   secret for the session cookie. Setting it turns on the SSO flow.
129    /// - `session.cookie.name` (string, default `"cas_session"`): session cookie name.
130    /// - `session.cookie.path` (string, default `"/"`): session cookie `Path`;
131    ///   scope to a subpath (e.g. `/app_a`) for independent per-app sessions.
132    /// - `session.cookie.lifetime` (u64 seconds, default `3600`): cookie lifetime.
133    /// - `logout_path` (string, optional): request path that clears the session
134    ///   cookie and redirects to `/`.
135    ///
136    /// ```yaml
137    /// type: cas-auth
138    /// config:
139    ///   idp_uri: https://cas.example.org/cas
140    ///   service: https://app.example.org/
141    ///   ssl_verify: true
142    ///   session:
143    ///     secret: ${CAS_SESSION_SECRET}
144    ///     cookie: { name: cas_session, lifetime: 3600 }
145    /// ```
146    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        // Interactive mode is enabled when a session secret is configured.
183        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    /// Builds a 401 rejection and exits on the `denied` port.
218    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    /// Builds a genuine infrastructure-failure `Err` (the CAS server was
233    /// unreachable, timed out, or answered `/serviceValidate` with a non-200).
234    /// Unlike [`CasAuthPlugin::reject`], this exits through the `error` port
235    /// because the node could not do its job, not because a presented ticket
236    /// was deliberately refused — and the prepared response is the shared
237    /// `502 provider_error` shape, not a `401` that would read as a bad ticket.
238    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    /// Session-store outage: 503 through the error port. Deliberately NOT
247    /// 401 — a store outage is not "unauthenticated".
248    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    /// Builds a `302` early-exit carrying the prepared response, and exits on
267    /// the `redirect` port. Wire the node's `redirect` edge to `client.in` so
268    /// this reaches the browser.
269    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    /// The service URL sent to `/serviceValidate`: the configured value, or one
289    /// derived from the request (`scheme://host/path`, without the query so the
290    /// ticket is dropped).
291    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    /// Cookie attributes for the session cookie: `HttpOnly`, `SameSite=Lax`, and
302    /// `Secure` only over HTTPS (so plain-HTTP dev works).
303    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    /// Attaches the authenticated user to the request/context.
314    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    /// Reads the session cookie via the configured backend, returning the
329    /// authenticated user. `Ok(None)` = unauthenticated (no cookie/sealer, or
330    /// an unopenable/unparseable payload); `Err` = store outage (503 via
331    /// [`CasAuthPlugin::store_error`]), never a silent re-login.
332    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    /// Validates a CAS ticket against `/serviceValidate`, returning the user.
351    ///
352    /// A transport failure or a non-200 reply is a [`CasError::Infra`] (the
353    /// node could not reach a verdict); a well-formed authentication-failure
354    /// reply is a [`CasError::Denied`] (CAS gave its verdict: no).
355    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    /// Interactive SSO flow: session cookie → callback → begin login.
379    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        // 0. Logout: revoke the session (store mode) and clear the cookie.
386        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        // 1. Valid session cookie → authenticate straight from it.
411        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        // 2. Callback: a CAS ticket came back on the service URL. Validate it,
421        //    establish a session (cookie or store), and redirect to the
422        //    ticket-free service URL.
423        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        // 3. No session, not a callback → begin login at the IdP.
452        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    /// Stateless ticket validation (the default, pre-interactive behavior).
458    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
475/// Classifies a `/serviceValidate` reply: a non-200 status is a provider
476/// failure ([`CasError::Infra`]); a 200 whose body carries no
477/// `authenticationSuccess`/user is CAS refusing the ticket
478/// ([`CasError::Denied`]).
479fn 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
489/// Reads the session secret from `session_secret` or nested `session.secret`.
490fn 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
504/// Reads a string field from nested `session.cookie.<key>`, falling back to the
505/// flat `session_cookie_<key>` form (used by the UI schema).
506fn 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
517/// Reads a u64 field from nested `session.cookie.<key>`, falling back to the
518/// flat `session_cookie_<key>` form (used by the UI schema).
519fn 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
528/// Percent-encodes a query-argument value (RFC3986 unreserved chars kept).
529fn 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
542/// Reads the CAS ticket from the request's query parameters.
543fn 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
551/// Builds the CAS `/serviceValidate` URL.
552fn 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
561/// Builds the CAS `/login?service=<service>` URL used to begin interactive login.
562fn build_login_url(idp_uri: &str, service: &str) -> String {
563    format!("{}/login?service={}", idp_uri, percent_encode(service))
564}
565
566/// Extracts the authenticated username from a CAS `/serviceValidate` response.
567///
568/// Handles both the default CAS 2.0 **XML** (`<cas:authenticationSuccess>` /
569/// `<cas:user>`, with or without the `cas:` prefix) and the CAS 3.0 **JSON**
570/// (`serviceResponse.authenticationSuccess.user`) formats. Returns `None` for
571/// an authentication-failure response or anything unparseable.
572fn parse_service_validate(body: &[u8]) -> Option<String> {
573    let text = std::str::from_utf8(body).ok()?;
574
575    // JSON format (format=json / CAS 3.0).
576    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    // XML format (default CAS 2.0).
588    if !text.contains("authenticationSuccess") {
589        return None;
590    }
591    extract_xml_tag(text, "cas:user").or_else(|| extract_xml_tag(text, "user"))
592}
593
594/// Returns the trimmed text between the first `<tag>` and its `</tag>`.
595fn 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        // No session secret ⇒ stateless (sealer absent), defaults populated.
666        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        // Nested form.
677        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        // Flat form the Web UI emits.
687        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        // top-level session_secret
701        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        // nested session.secret + cookie overrides
708        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        // empty ticket ignored
732        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        // no cas: prefix
780        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    /// CAS answered and refused the ticket → a deliberate denial.
810    #[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        // Unparseable 200 body is likewise "CAS said no".
818        match classify_validation(200, b"garbage") {
819            Err(CasError::Denied(_)) => {}
820            other => panic!("expected Denied, got {other:?}"),
821        }
822        // A success body still yields the user.
823        let ok = b"<serviceResponse><authenticationSuccess><user>eve</user></authenticationSuccess></serviceResponse>";
824        assert_eq!(classify_validation(200, ok).unwrap(), "eve");
825    }
826
827    /// A non-200 from `/serviceValidate` means the node never got a verdict —
828    /// an infrastructure failure, not a denial.
829    #[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    /// Transport failure (nothing listening on 127.0.0.1:1) must exit through
840    /// the `error` port as an `Err`, NOT as a `denied` outcome.
841    #[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        // A CAS outage is a provider failure, not a refused ticket: 502, no challenge.
862        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        // Interactive mode, no cookie, no ticket → 302 to the IdP /login.
882        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        // No cookie is set when merely beginning login.
899        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        // Seal a session cookie the way a successful callback would.
913        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    /// redis storage requires a store name; unknown stores fail at config.
971    #[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        // `.err().unwrap()` (not `unwrap_err()`): the Ok type isn't `Debug`.
984        let err = CasAuthPlugin::from_config(&cfg, &PluginResources::empty())
985            .err()
986            .unwrap();
987        assert!(err.contains("requires 'session.store'"), "{err}");
988    }
989
990    /// In redis mode a valid session cookie authenticates from the store, and
991    /// a store outage is a 503 on the error port — never a silent re-login.
992    #[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        // Establish a session by hand: seal a CasSession, put under an id.
1011        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        // Outage: same request, failing store.
1044        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}