Skip to main content

featherbit/plugins/native/
authz_casdoor.rs

1//! Casdoor authorization plugin (`authz-casdoor`).
2//!
3//! Port of Apache APISIX's `authz-casdoor` plugin, with two modes:
4//!
5//! - **Stateless (default)** — when no session secret is configured, the node
6//!   acts as a **bearer-token validator**: a Casdoor access token presented in
7//!   the `Authorization` header is validated by calling Casdoor's OAuth **token
8//!   introspection** endpoint (`/api/login/oauth/introspect`, RFC 7662)
9//!   authenticated with the client credentials. `active: true` allows the
10//!   request; a missing/inactive/invalid token denies it (`403`) on the
11//!   `denied` port; an unreachable introspection endpoint is a genuine
12//!   callout failure and exits on `error` instead.
13//! - **Interactive (opt-in)** — set `session_secret` (or `session.secret`) to
14//!   turn on the full **OAuth Authorization Code** login flow using the shared
15//!   [encrypted-cookie session primitive](crate::plugins::util::cookie_session).
16//!   Unauthenticated browsers are redirected to Casdoor's authorize URL; the
17//!   callback exchanges the `code` for an access token, which is sealed into an
18//!   encrypted client-side cookie (no server-side session store). See the
19//!   three-branch logic in [`AuthzCasdoorPlugin::execute_interactive`].
20//!
21//! ## Redirect wiring (interactive mode)
22//!
23//! A `302` produced by this node (login redirect, post-callback redirect, or
24//! logout) exits on the dedicated **`redirect`** output port, following the
25//! same convention as the standalone `redirect` node. **Wire the node's
26//! `redirect` edge to `client.in`** so it reaches the browser; deliberate
27//! denials (missing/invalid token, OAuth state mismatch, Casdoor refusing the
28//! decision) exit on **`denied`** (also wired to `client.in`, or a custom
29//! denial handler); a genuine Casdoor callout failure (token exchange or
30//! introspection unreachable) exits through the ordinary **`error`** port
31//! since the node could not do its job; the `success` edge carries
32//! authenticated requests on to the upstream.
33
34use async_trait::async_trait;
35use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
36use base64::Engine;
37use bytes::Bytes;
38use ring::rand::{SecureRandom, SystemRandom};
39use serde::{Deserialize, Serialize};
40use std::collections::HashMap;
41use std::sync::Arc;
42use std::time::Duration;
43
44use crate::context::{Context, GatewayError};
45use crate::outbound::{OutboundClient, OutboundError, OutboundRequest};
46use crate::plugins::resources::PluginResources;
47use crate::plugins::util::cookie_session::{
48    build_set_cookie, delete_cookie, path_covers, read_cookie, CookieAttrs, CookieSealer, SameSite,
49};
50use crate::plugins::util::server_session::{self, SessionBackend};
51use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
52use crate::sessions::StoreError;
53
54/// Session payload sealed into the Casdoor session cookie (interactive mode).
55#[derive(Debug, Clone, Serialize, Deserialize)]
56struct CasdoorSession {
57    /// The Casdoor access token minted at the callback.
58    access_token: String,
59    /// The client id this session was issued under (guards cross-config reuse).
60    client_id: String,
61    /// Decoded access-token claims, when the token is a JWT.
62    #[serde(default)]
63    claims: Option<serde_json::Value>,
64}
65
66/// Transient login-flow payload sealed into the short-lived flow cookie.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68struct CasdoorFlow {
69    /// Anti-CSRF `state` echoed back on the callback.
70    state: String,
71    /// URI to return the browser to after login completes.
72    original_uri: String,
73}
74
75/// Validates a Casdoor access token, and in interactive mode runs the SSO flow.
76pub struct AuthzCasdoorPlugin {
77    /// Casdoor server base URL, without a trailing slash.
78    endpoint_addr: String,
79    /// Casdoor application client id.
80    client_id: String,
81    /// Casdoor application client secret.
82    client_secret: String,
83    /// `Authorization: Basic ...` header value built from the client credentials.
84    basic_auth: String,
85    /// TLS certificate verification for the callout.
86    ssl_verify: bool,
87    /// Whole-call timeout for the callout.
88    timeout: Duration,
89    /// When set, interactive SSO login is enabled and this seals/opens cookies.
90    sealer: Option<CookieSealer>,
91    /// Full callback URL registered with Casdoor (the OAuth `redirect_uri`).
92    callback_url: Option<String>,
93    /// Path component of `callback_url`, matched against the request path.
94    callback_path: Option<String>,
95    /// OAuth `scope` requested at the authorize step (default `read`).
96    scope: String,
97    /// Session cookie name (interactive mode).
98    cookie_name: String,
99    /// Transient login-flow cookie name (interactive mode).
100    flow_cookie_name: String,
101    /// `Path` attribute of the session and flow cookies (interactive mode).
102    /// Scope to a subpath (e.g. `/app_a`) for independent per-app sessions;
103    /// must cover `callback_path`. Defaults to `/`.
104    cookie_path: String,
105    /// Session cookie lifetime in seconds (interactive mode).
106    cookie_lifetime: u64,
107    /// Optional logout path; a request to it clears the session cookie.
108    logout_path: Option<String>,
109    /// Where the session payload lives: sealed in the cookie itself, or a
110    /// server-side store keyed by a bare id in the cookie. See
111    /// `session.storage` / `session.store` in [`AuthzCasdoorPlugin::from_config`].
112    backend: SessionBackend,
113    /// Randomness source for the anti-CSRF `state`.
114    rng: SystemRandom,
115    /// Shared pooled outbound HTTP client.
116    outbound: Arc<OutboundClient>,
117}
118
119impl AuthzCasdoorPlugin {
120    /// Builds the plugin from node config.
121    ///
122    /// Accepted keys:
123    /// - `endpoint_addr` (string, **required**): Casdoor base URL (a trailing
124    ///   `/` is trimmed).
125    /// - `client_id` (string, **required**): Casdoor application client id.
126    /// - `client_secret` (string, **required**): Casdoor application client
127    ///   secret. Used for HTTP Basic auth on the introspection call (stateless)
128    ///   and the code-exchange call (interactive).
129    /// - `callback_url` (string): OAuth `redirect_uri`. **Required in interactive
130    ///   mode**; accepted-but-unused in stateless mode.
131    /// - `ssl_verify` (bool, default `true`): verify the endpoint's TLS certificate.
132    /// - `timeout` (integer ms, default `3000`): callout timeout.
133    ///
134    /// Interactive-mode keys (a session secret ⇒ interactive login is enabled):
135    /// - `session_secret` (string) or `session.secret` (string): signing/encryption
136    ///   secret for the session and flow cookies. Setting it turns on the SSO flow.
137    /// - `session.cookie.name` (string, default `"casdoor_session"`): session cookie name.
138    /// - `session.cookie.path` (string, default `"/"`): session/flow cookie `Path`;
139    ///   scope to a subpath (e.g. `/app_a`) for independent per-app sessions. Must
140    ///   cover the `callback_url` path (rejected at load otherwise).
141    /// - `session.cookie.lifetime` (u64 seconds, default `3600`): cookie lifetime.
142    /// - `scope` (string, default `"read"`): OAuth scope requested at authorize.
143    /// - `logout_path` (string, optional): request path that clears the session
144    ///   cookie and redirects to `/`.
145    ///
146    /// ```yaml
147    /// - id: authz
148    ///   type: authz-casdoor
149    ///   config:
150    ///     endpoint_addr: https://casdoor.example.com
151    ///     client_id: ${CASDOOR_CLIENT_ID}
152    ///     client_secret: ${CASDOOR_CLIENT_SECRET}
153    ///     callback_url: https://app.example.com/casdoor/callback
154    ///     session_secret: ${CASDOOR_SESSION_SECRET}
155    ///     scope: read
156    /// ```
157    pub fn from_config(
158        config: &HashMap<String, serde_json::Value>,
159        resources: &Arc<PluginResources>,
160    ) -> Result<Self, String> {
161        let endpoint_addr = config
162            .get("endpoint_addr")
163            .and_then(|v| v.as_str())
164            .filter(|s| !s.is_empty())
165            .ok_or_else(|| "authz-casdoor requires 'endpoint_addr'".to_string())?
166            .trim_end_matches('/')
167            .to_string();
168
169        let client_id = config
170            .get("client_id")
171            .and_then(|v| v.as_str())
172            .filter(|s| !s.is_empty())
173            .ok_or_else(|| "authz-casdoor requires 'client_id'".to_string())?
174            .to_string();
175
176        let client_secret = config
177            .get("client_secret")
178            .and_then(|v| v.as_str())
179            .filter(|s| !s.is_empty())
180            .ok_or_else(|| "authz-casdoor requires 'client_secret'".to_string())?
181            .to_string();
182
183        let ssl_verify = config
184            .get("ssl_verify")
185            .and_then(|v| v.as_bool())
186            .unwrap_or(true);
187
188        let timeout_ms = config
189            .get("timeout")
190            .and_then(|v| v.as_u64())
191            .unwrap_or(3000);
192
193        let callback_url = config
194            .get("callback_url")
195            .and_then(|v| v.as_str())
196            .filter(|s| !s.is_empty())
197            .map(|s| s.trim_end_matches('/').to_string());
198        let callback_path = callback_url.as_deref().and_then(callback_path_of);
199
200        // Interactive mode is enabled when a session secret is configured.
201        let sealer = session_secret(config).map(|s| CookieSealer::new(&s));
202        if sealer.is_some() && callback_path.is_none() {
203            return Err(
204                "authz-casdoor interactive mode (session_secret set) requires a 'callback_url' \
205                 with a path component"
206                    .to_string(),
207            );
208        }
209
210        let scope = config
211            .get("scope")
212            .and_then(|v| v.as_str())
213            .filter(|s| !s.is_empty())
214            .unwrap_or("read")
215            .to_string();
216        let cookie_name =
217            session_cookie_str(config, "name").unwrap_or_else(|| "casdoor_session".to_string());
218        let flow_cookie_name = format!("{cookie_name}_flow");
219        let cookie_path = session_cookie_str(config, "path").unwrap_or_else(|| "/".to_string());
220        // The callback must receive the flow/session cookies, so the cookie path
221        // has to cover the callback path (interactive mode only, where a callback
222        // path is guaranteed present by the check above). Otherwise login loops.
223        if let Some(cb) = callback_path.as_deref() {
224            if sealer.is_some() && !path_covers(&cookie_path, cb) {
225                return Err(format!(
226                    "authz-casdoor: session.cookie.path '{}' does not cover the callback_url \
227                     path '{}'; the session cookie would not reach the callback and login \
228                     would loop. Set session.cookie.path to a prefix of the callback path.",
229                    cookie_path, cb
230                ));
231            }
232        }
233        let cookie_lifetime = session_cookie_u64(config, "lifetime").unwrap_or(3_600);
234        let logout_path = config
235            .get("logout_path")
236            .and_then(|v| v.as_str())
237            .filter(|s| !s.is_empty())
238            .map(String::from);
239
240        let backend = server_session::parse_backend(config, resources, "authz-casdoor")?;
241        if sealer.is_none() && !matches!(backend, SessionBackend::Cookie) {
242            return Err(
243                "authz-casdoor: session.storage requires session_secret (interactive mode)"
244                    .to_string(),
245            );
246        }
247
248        Ok(Self {
249            basic_auth: basic_auth_header(&client_id, &client_secret),
250            endpoint_addr,
251            client_id,
252            client_secret,
253            ssl_verify,
254            timeout: Duration::from_millis(timeout_ms),
255            sealer,
256            callback_url,
257            callback_path,
258            scope,
259            cookie_name,
260            flow_cookie_name,
261            cookie_path,
262            cookie_lifetime,
263            logout_path,
264            backend,
265            rng: SystemRandom::new(),
266            outbound: resources.outbound.clone(),
267        })
268    }
269
270    /// Builds the 403 denial and exits on the `denied` port. Reserved for
271    /// deliberate denials — a missing/invalid token, OAuth state mismatch, or
272    /// Casdoor actively refusing the decision.
273    fn deny(ctx: Context, message: impl Into<String>) -> PluginResult {
274        // The reason never reaches the client (the `denied` port carries no
275        // error record, unlike `Err`), but it's still useful for operators
276        // debugging why a request was denied.
277        tracing::debug!("authz-casdoor: denying request: {}", message.into());
278        let mut ctx = ctx;
279        ctx.response.status_code = 403;
280        ctx.response.body = Bytes::from(r#"{"error":"access_denied"}"#);
281        ctx.response.headers.insert(
282            "content-type".to_string(),
283            vec!["application/json".to_string()],
284        );
285        Ok(PluginOutput::on_port(ctx, "denied"))
286    }
287
288    /// Builds a `302` early-exit and exits on the `redirect` port. Wire the
289    /// node's `redirect` edge to `client.in` so this reaches the browser.
290    fn redirect(mut ctx: Context, location: String, set_cookies: Vec<String>) -> PluginResult {
291        ctx.response.status_code = 302;
292        ctx.response
293            .headers
294            .insert("location".to_string(), vec![location]);
295        if !set_cookies.is_empty() {
296            ctx.response
297                .headers
298                .insert("set-cookie".to_string(), set_cookies);
299        }
300        ctx.response.body = Bytes::new();
301        Ok(PluginOutput::on_port(ctx, "redirect"))
302    }
303
304    /// Builds a genuine infrastructure-failure `Err` (a Casdoor token-exchange
305    /// or introspection callout that transport-failed, timed out, or returned
306    /// an unparseable response) — exits through the `error` port because the
307    /// node could not do its job, unlike `deny`/`redirect` which are
308    /// deliberate, client-facing outcomes. The prepared response is the shared
309    /// `502 provider_error` shape, not a `403 access_denied`.
310    fn callout_error(ctx: Context, message: String) -> PluginResult {
311        Err(crate::plugins::util::provider_error::provider_error(
312            ctx,
313            "AUTHZ_CASDOOR_ERROR",
314            message,
315        ))
316    }
317
318    /// Session-store outage: 503 through the error port. Deliberately NOT
319    /// 401 — a store outage is not "unauthenticated".
320    fn store_error(mut ctx: Context, e: StoreError) -> PluginExecutionError {
321        ctx.response.status_code = 503;
322        ctx.response.body = Bytes::from(r#"{"error": "session store unavailable"}"#.as_bytes());
323        ctx.response.headers.insert(
324            "content-type".to_string(),
325            vec!["application/json".to_string()],
326        );
327        PluginExecutionError {
328            context: ctx,
329            error: GatewayError {
330                node_id: String::new(),
331                code: "SESSION_STORE_ERROR".to_string(),
332                message: e.to_string(),
333                metadata: HashMap::new(),
334            },
335        }
336    }
337
338    /// Cookie attributes: `HttpOnly`, `SameSite=Lax`, `Secure` only over HTTPS.
339    fn cookie_attrs(&self, ctx: &Context, max_age: u64) -> CookieAttrs<'_> {
340        CookieAttrs {
341            path: &self.cookie_path,
342            max_age: Some(max_age),
343            http_only: true,
344            secure: ctx.request.scheme == "https",
345            same_site: SameSite::Lax,
346        }
347    }
348
349    /// Reads the session cookie via the configured backend, returning the
350    /// sealed session. `Ok(None)` = unauthenticated (no cookie/sealer, or an
351    /// unopenable/unparseable payload); `Err` = store outage (503 via
352    /// [`AuthzCasdoorPlugin::store_error`]), never a silent re-login.
353    async fn read_session(&self, ctx: &Context) -> Result<Option<CasdoorSession>, StoreError> {
354        let Some(sealer) = self.sealer.as_ref() else {
355            return Ok(None);
356        };
357        let Some(cookie_header) = ctx.request.headers.get("cookie").and_then(|v| v.first()) else {
358            return Ok(None);
359        };
360        let Some(raw) = read_cookie(cookie_header, &self.cookie_name) else {
361            return Ok(None);
362        };
363        let bytes = server_session::load(&self.backend, sealer, raw).await?;
364        Ok(bytes.and_then(|b| serde_json::from_slice(&b).ok()))
365    }
366
367    /// Reads and opens the transient login-flow cookie.
368    fn read_flow(&self, ctx: &Context) -> Option<CasdoorFlow> {
369        let sealer = self.sealer.as_ref()?;
370        let cookie_header = ctx.request.headers.get("cookie").and_then(|v| v.first())?;
371        let raw = read_cookie(cookie_header, &self.flow_cookie_name)?;
372        let payload = sealer.open(raw).ok()?;
373        serde_json::from_slice(&payload).ok()
374    }
375
376    /// Attaches the authenticated identity from a session to the request.
377    fn attach_session(&self, ctx: &mut Context, session: &CasdoorSession) {
378        ctx.request.headers.insert(
379            "authorization".to_string(),
380            vec![format!("Bearer {}", session.access_token)],
381        );
382        if let Some(claims) = &session.claims {
383            if let Some(sub) = claims.get("sub") {
384                ctx.message.insert("user_id".to_string(), sub.clone());
385            }
386            ctx.message.insert("jwt_claims".to_string(), claims.clone());
387        }
388    }
389
390    /// Exchanges an authorization `code` for a Casdoor access token.
391    async fn fetch_access_token(&self, code: &str) -> Result<String, String> {
392        let request = OutboundRequest {
393            method: http::Method::POST,
394            url: format!("{}/api/login/oauth/access_token", self.endpoint_addr),
395            headers: vec![(
396                "content-type".to_string(),
397                "application/x-www-form-urlencoded".to_string(),
398            )],
399            body: Bytes::from(access_token_body(
400                code,
401                &self.client_id,
402                &self.client_secret,
403            )),
404            timeout: self.timeout,
405            ssl_verify: self.ssl_verify,
406            tls: None,
407        };
408        let resp = self
409            .outbound
410            .request(request)
411            .await
412            .map_err(|e| format!("Casdoor token exchange failed: {e}"))?;
413        if resp.status != 200 {
414            return Err(format!(
415                "Casdoor token endpoint returned status {}",
416                resp.status
417            ));
418        }
419        parse_access_token(&resp.body)
420    }
421
422    /// Interactive SSO flow: callback → valid session → begin login.
423    async fn execute_interactive(&self, mut ctx: Context) -> PluginResult {
424        let sealer = self
425            .sealer
426            .as_ref()
427            .expect("execute_interactive only called when a sealer is configured");
428
429        // 0. Logout: revoke the session (store mode) and clear the cookie.
430        if let Some(ref logout_path) = self.logout_path {
431            if &ctx.request.path == logout_path {
432                let cookie_value = ctx
433                    .request
434                    .headers
435                    .get("cookie")
436                    .and_then(|v| v.first())
437                    .and_then(|h| read_cookie(h, &self.cookie_name))
438                    .map(str::to_string);
439                let del = match server_session::destroy(
440                    &self.backend,
441                    cookie_value.as_deref(),
442                    &self.cookie_name,
443                    &self.cookie_path,
444                )
445                .await
446                {
447                    Ok(c) => c,
448                    Err(e) => return Err(Self::store_error(ctx, e)),
449                };
450                return Self::redirect(ctx, "/".to_string(), vec![del]);
451            }
452        }
453
454        // 1. Callback: request path matches callback_url path and carries
455        //    code+state. Validate state, exchange the code, seal a session.
456        if self.is_callback(&ctx) {
457            return self.handle_callback(ctx, sealer).await;
458        }
459
460        // 2. Valid session cookie (for this client_id) → authenticate from it.
461        match self.read_session(&ctx).await {
462            Ok(Some(session)) if session.client_id == self.client_id => {
463                self.attach_session(&mut ctx, &session);
464                return Ok(PluginOutput::success(ctx));
465            }
466            Ok(_) => {}
467            Err(e) => return Err(Self::store_error(ctx, e)),
468        }
469
470        // 3. No session, not a callback → begin login at Casdoor.
471        self.begin_login(ctx, sealer)
472    }
473
474    /// True when the request is the OAuth callback (path + `code` + `state`).
475    fn is_callback(&self, ctx: &Context) -> bool {
476        self.callback_path.as_deref() == Some(ctx.request.path.as_str())
477            && ctx.request.query_params.contains_key("code")
478            && ctx.request.query_params.contains_key("state")
479    }
480
481    /// Handles the OAuth callback: verify state, exchange code, seal a session,
482    /// and redirect to the original URI.
483    async fn handle_callback(&self, ctx: Context, sealer: &CookieSealer) -> PluginResult {
484        let flow = match self.read_flow(&ctx) {
485            Some(f) => f,
486            None => return Self::deny(ctx, "missing or invalid login-flow cookie"),
487        };
488        let state = query_first(&ctx, "state").unwrap_or_default();
489        if state != flow.state {
490            return Self::deny(ctx, "OAuth state mismatch");
491        }
492        let code = match query_first(&ctx, "code") {
493            Some(c) if !c.is_empty() => c,
494            _ => return Self::deny(ctx, "missing authorization code"),
495        };
496
497        let access_token = match self.fetch_access_token(&code).await {
498            Ok(t) => t,
499            Err(e) => return Self::callout_error(ctx, e),
500        };
501
502        let claims = decode_jwt_claims(&access_token);
503        // Opaque (non-JWT) tokens have no claims, so `sub` may be absent —
504        // an empty subject is correct here and deliberately unindexed.
505        let subject = claims
506            .as_ref()
507            .and_then(|c| c.get("sub"))
508            .and_then(|v| v.as_str())
509            .unwrap_or("")
510            .to_string();
511        let session = CasdoorSession {
512            access_token,
513            client_id: self.client_id.clone(),
514            claims,
515        };
516        let payload = serde_json::to_vec(&session).unwrap_or_default();
517        let ttl = Duration::from_secs(self.cookie_lifetime);
518        let meta = server_session::meta_now(&ctx, "authz-casdoor", &subject, ttl);
519        let set_session = match server_session::establish(
520            &self.backend,
521            sealer,
522            &payload,
523            ttl,
524            meta,
525            &self.cookie_name,
526            &self.cookie_attrs(&ctx, self.cookie_lifetime),
527        )
528        .await
529        {
530            Ok(s) => s,
531            Err(e) => return Err(Self::store_error(ctx, e)),
532        };
533        let del_flow = delete_cookie(&self.flow_cookie_name, &self.cookie_path);
534        Self::redirect(ctx, flow.original_uri, vec![set_session, del_flow])
535    }
536
537    /// Begins interactive login: mint a `state`, stash it plus the original URI
538    /// in a short-lived flow cookie, and redirect to Casdoor's authorize URL.
539    fn begin_login(&self, ctx: Context, sealer: &CookieSealer) -> PluginResult {
540        let state = random_state(&self.rng);
541        let original_uri = reconstruct_uri(&ctx);
542        let flow = CasdoorFlow {
543            state: state.clone(),
544            original_uri,
545        };
546        let payload = serde_json::to_vec(&flow).unwrap_or_default();
547        // Short-lived: the flow cookie only needs to survive the round trip.
548        let sealed = sealer.seal(&payload, Duration::from_secs(300));
549        let set_flow = build_set_cookie(
550            &self.flow_cookie_name,
551            &sealed,
552            &self.cookie_attrs(&ctx, 300),
553        );
554
555        let callback = self.callback_url.as_deref().unwrap_or("");
556        let authorize = build_authorize_url(
557            &self.endpoint_addr,
558            &self.client_id,
559            callback,
560            &state,
561            &self.scope,
562        );
563        Self::redirect(ctx, authorize, vec![set_flow])
564    }
565
566    /// Stateless bearer-token validation via introspection (default behavior).
567    async fn execute_stateless(&self, ctx: Context) -> PluginResult {
568        let token = match extract_token(&ctx) {
569            Some(t) => t,
570            None => return Self::deny(ctx, "missing Casdoor access token"),
571        };
572
573        let request = OutboundRequest {
574            method: http::Method::POST,
575            url: introspect_url(&self.endpoint_addr),
576            headers: vec![
577                (
578                    "content-type".to_string(),
579                    "application/x-www-form-urlencoded".to_string(),
580                ),
581                ("authorization".to_string(), self.basic_auth.clone()),
582            ],
583            body: Bytes::from(introspect_body(&token)),
584            timeout: self.timeout,
585            ssl_verify: self.ssl_verify,
586            tls: None,
587        };
588
589        match self.outbound.request(request).await {
590            Ok(resp) if resp.status == 200 && token_is_active(&resp.body) => {
591                Ok(PluginOutput::success(ctx))
592            }
593            // Per RFC 7662, a `200` with `active: false` is the introspection
594            // endpoint doing its job and saying "no" — a deliberate denial.
595            Ok(resp) if resp.status == 200 => Self::deny(ctx, "Casdoor token inactive"),
596            // A non-200 from the introspection endpoint is not a token
597            // decision at all (e.g. the gateway's own client credentials were
598            // rejected, or a fronting proxy is down) — a genuine callout
599            // failure, not a deliberate denial.
600            Ok(resp) => Self::callout_error(
601                ctx,
602                format!("Casdoor introspection returned status {}", resp.status),
603            ),
604            Err(e) => {
605                let detail = match &e {
606                    OutboundError::Timeout(d) => format!("Casdoor request timed out after {d:?}"),
607                    OutboundError::InvalidRequest(m) => format!("invalid Casdoor request: {m}"),
608                    OutboundError::Transport(m) => format!("Casdoor request failed: {m}"),
609                };
610                Self::callout_error(ctx, detail)
611            }
612        }
613    }
614}
615
616/// Reads the session secret from `session_secret` or nested `session.secret`.
617fn session_secret(config: &HashMap<String, serde_json::Value>) -> Option<String> {
618    config
619        .get("session_secret")
620        .and_then(|v| v.as_str())
621        .or_else(|| {
622            config
623                .get("session")
624                .and_then(|s| s.get("secret"))
625                .and_then(|v| v.as_str())
626        })
627        .filter(|s| !s.is_empty())
628        .map(String::from)
629}
630
631/// Reads a string field from nested `session.cookie.<key>`, falling back to the
632/// flat `session_cookie_<key>` form (used by the UI schema).
633fn session_cookie_str(config: &HashMap<String, serde_json::Value>, key: &str) -> Option<String> {
634    config
635        .get("session")
636        .and_then(|s| s.get("cookie"))
637        .and_then(|c| c.get(key))
638        .or_else(|| config.get(&format!("session_cookie_{key}")))
639        .and_then(|v| v.as_str())
640        .filter(|s| !s.is_empty())
641        .map(String::from)
642}
643
644/// Reads a u64 field from nested `session.cookie.<key>`, falling back to the
645/// flat `session_cookie_<key>` form (used by the UI schema).
646fn session_cookie_u64(config: &HashMap<String, serde_json::Value>, key: &str) -> Option<u64> {
647    config
648        .get("session")
649        .and_then(|s| s.get("cookie"))
650        .and_then(|c| c.get(key))
651        .or_else(|| config.get(&format!("session_cookie_{key}")))
652        .and_then(|v| v.as_u64())
653}
654
655/// Extracts the path component of a callback URL (`https://h/p?x` → `/p`).
656/// Mirrors the APISIX regex `.+//[^/]+(/.*)`.
657fn callback_path_of(url: &str) -> Option<String> {
658    let after_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
659    let slash = after_scheme.find('/')?;
660    let path = &after_scheme[slash..];
661    // Strip query/fragment; the request path never carries them.
662    let path = path.split(['?', '#']).next().unwrap_or(path);
663    if path.is_empty() {
664        None
665    } else {
666        Some(path.to_string())
667    }
668}
669
670/// Builds the `Authorization: Basic <base64(client_id:client_secret)>` value.
671fn basic_auth_header(client_id: &str, client_secret: &str) -> String {
672    let raw = format!("{client_id}:{client_secret}");
673    format!("Basic {}", STANDARD.encode(raw.as_bytes()))
674}
675
676/// The Casdoor OAuth token-introspection endpoint for a base URL.
677fn introspect_url(endpoint_addr: &str) -> String {
678    format!("{endpoint_addr}/api/login/oauth/introspect")
679}
680
681/// Builds the Casdoor authorize URL that begins the login handshake.
682fn build_authorize_url(
683    endpoint_addr: &str,
684    client_id: &str,
685    callback_url: &str,
686    state: &str,
687    scope: &str,
688) -> String {
689    format!(
690        "{}/login/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&scope={}",
691        endpoint_addr,
692        form_encode(client_id),
693        form_encode(callback_url),
694        form_encode(state),
695        form_encode(scope),
696    )
697}
698
699/// Encodes the code-exchange request body.
700fn access_token_body(code: &str, client_id: &str, client_secret: &str) -> String {
701    format!(
702        "grant_type=authorization_code&code={}&client_id={}&client_secret={}",
703        form_encode(code),
704        form_encode(client_id),
705        form_encode(client_secret),
706    )
707}
708
709/// Parses the token endpoint's JSON, returning the `access_token` when the
710/// reply is a valid, unexpired grant (`expires_in > 0`, per Casdoor).
711fn parse_access_token(body: &[u8]) -> Result<String, String> {
712    let data: serde_json::Value =
713        serde_json::from_slice(body).map_err(|e| format!("failed to parse Casdoor token: {e}"))?;
714    let token = data
715        .get("access_token")
716        .and_then(|v| v.as_str())
717        .filter(|s| !s.is_empty())
718        .ok_or_else(|| "Casdoor token response missing access_token".to_string())?;
719    // Casdoor signals an invalid token with expires_in <= 0.
720    if let Some(expires) = data.get("expires_in") {
721        let secs = expires
722            .as_i64()
723            .or_else(|| expires.as_str().and_then(|s| s.parse().ok()));
724        if matches!(secs, Some(n) if n <= 0) {
725            return Err("Casdoor returned an expired/invalid access_token".to_string());
726        }
727    }
728    Ok(token.to_string())
729}
730
731/// Decodes a JWT's claim set (middle segment) without verifying the signature.
732/// The token came directly from Casdoor's token endpoint over TLS, so it is
733/// trusted here; the claims are only used to surface identity to the upstream.
734fn decode_jwt_claims(token: &str) -> Option<serde_json::Value> {
735    let mut parts = token.split('.');
736    let _header = parts.next()?;
737    let payload = parts.next()?;
738    let bytes = URL_SAFE_NO_PAD.decode(payload).ok()?;
739    let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
740    if value.is_object() {
741        Some(value)
742    } else {
743        None
744    }
745}
746
747/// Generates a random anti-CSRF `state` (128 bits, hex-encoded).
748fn random_state(rng: &SystemRandom) -> String {
749    let mut bytes = [0u8; 16];
750    rng.fill(&mut bytes).expect("system RNG must produce state");
751    bytes.iter().map(|b| format!("{b:02x}")).collect()
752}
753
754/// Reconstructs the request URI (`path` plus a best-effort query string) so the
755/// browser can be returned there after login.
756fn reconstruct_uri(ctx: &Context) -> String {
757    let mut uri = ctx.request.path.clone();
758    if !ctx.request.query_params.is_empty() {
759        let mut pairs: Vec<String> = Vec::new();
760        for (k, values) in &ctx.request.query_params {
761            for v in values {
762                if v.is_empty() {
763                    pairs.push(k.clone());
764                } else {
765                    pairs.push(format!("{k}={v}"));
766                }
767            }
768        }
769        uri.push('?');
770        uri.push_str(&pairs.join("&"));
771    }
772    uri
773}
774
775/// The first value of a query parameter.
776fn query_first(ctx: &Context, key: &str) -> Option<String> {
777    ctx.request
778        .query_params
779        .get(key)
780        .and_then(|v| v.first())
781        .cloned()
782}
783
784/// Extracts the raw bearer token (without the `Bearer ` prefix) from the
785/// `Authorization` header.
786fn extract_token(ctx: &Context) -> Option<String> {
787    let raw = ctx
788        .request
789        .headers
790        .get("authorization")
791        .and_then(|v| v.first())?
792        .as_str();
793    let stripped = raw
794        .strip_prefix("Bearer ")
795        .or_else(|| raw.strip_prefix("bearer "))
796        .unwrap_or(raw);
797    let token = stripped.trim();
798    if token.is_empty() {
799        None
800    } else {
801        Some(token.to_string())
802    }
803}
804
805/// Encodes the introspection request body (`token` + `token_type_hint`).
806fn introspect_body(token: &str) -> String {
807    format!("token={}&token_type_hint=access_token", form_encode(token))
808}
809
810/// Percent-encodes a value for `application/x-www-form-urlencoded` bodies.
811fn form_encode(s: &str) -> String {
812    let mut out = String::with_capacity(s.len());
813    for &b in s.as_bytes() {
814        match b {
815            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
816                out.push(b as char)
817            }
818            b' ' => out.push('+'),
819            _ => out.push_str(&format!("%{b:02X}")),
820        }
821    }
822    out
823}
824
825/// Parses an RFC 7662 introspection response, returning the `active` flag.
826/// A missing/false/non-boolean `active` is treated as inactive.
827fn token_is_active(body: &[u8]) -> bool {
828    serde_json::from_slice::<serde_json::Value>(body)
829        .ok()
830        .and_then(|v| v.get("active").and_then(|a| a.as_bool()))
831        .unwrap_or(false)
832}
833
834#[async_trait]
835impl Plugin for AuthzCasdoorPlugin {
836    fn plugin_type(&self) -> &str {
837        "authz-casdoor"
838    }
839
840    async fn execute(&self, ctx: Context) -> PluginResult {
841        if self.sealer.is_some() {
842            self.execute_interactive(ctx).await
843        } else {
844            self.execute_stateless(ctx).await
845        }
846    }
847}
848
849#[cfg(test)]
850mod tests {
851    use super::*;
852    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
853
854    fn ctx_with_auth(auth: Option<&str>) -> Context {
855        let mut headers = HashMap::new();
856        if let Some(a) = auth {
857            headers.insert("authorization".to_string(), vec![a.to_string()]);
858        }
859        Context {
860            request: GatewayRequest {
861                method: "GET".to_string(),
862                path: "/data".to_string(),
863                host: "h".to_string(),
864                scheme: "http".to_string(),
865                headers,
866                query_params: HashMap::new(),
867                body: Bytes::new(),
868                remote_addr: "1.2.3.4:5".to_string(),
869                protocol: Protocol::Http1,
870            },
871            response: GatewayResponse {
872                status_code: 0,
873                headers: HashMap::new(),
874                body: Bytes::new(),
875                stream: None,
876            },
877            message: HashMap::new(),
878            errors: Vec::new(),
879        }
880    }
881
882    fn ctx(path: &str, query: HashMap<String, Vec<String>>) -> Context {
883        Context {
884            request: GatewayRequest {
885                method: "GET".to_string(),
886                path: path.to_string(),
887                host: "app.example.com".to_string(),
888                scheme: "https".to_string(),
889                headers: HashMap::new(),
890                query_params: query,
891                body: Bytes::new(),
892                remote_addr: "1.2.3.4:5".to_string(),
893                protocol: Protocol::Http1,
894            },
895            response: GatewayResponse {
896                status_code: 0,
897                headers: HashMap::new(),
898                body: Bytes::new(),
899                stream: None,
900            },
901            message: HashMap::new(),
902            errors: Vec::new(),
903        }
904    }
905
906    fn stateless_cfg() -> HashMap<String, serde_json::Value> {
907        let mut config = HashMap::new();
908        config.insert(
909            "endpoint_addr".to_string(),
910            serde_json::json!("https://casdoor.example.com/"),
911        );
912        config.insert("client_id".to_string(), serde_json::json!("id"));
913        config.insert("client_secret".to_string(), serde_json::json!("secret"));
914        config
915    }
916
917    fn interactive_cfg() -> HashMap<String, serde_json::Value> {
918        let mut config = stateless_cfg();
919        config.insert(
920            "callback_url".to_string(),
921            serde_json::json!("https://app.example.com/casdoor/callback"),
922        );
923        config.insert("session_secret".to_string(), serde_json::json!("s3cr3t"));
924        config
925    }
926
927    #[test]
928    fn test_basic_auth_header() {
929        // base64("id:secret") = aWQ6c2VjcmV0
930        assert_eq!(basic_auth_header("id", "secret"), "Basic aWQ6c2VjcmV0");
931    }
932
933    #[test]
934    fn test_introspect_url_and_body() {
935        assert_eq!(
936            introspect_url("https://casdoor.example.com"),
937            "https://casdoor.example.com/api/login/oauth/introspect"
938        );
939        assert_eq!(
940            introspect_body("abc.def"),
941            "token=abc.def&token_type_hint=access_token"
942        );
943    }
944
945    #[test]
946    fn test_extract_token() {
947        assert_eq!(
948            extract_token(&ctx_with_auth(Some("Bearer abc"))).as_deref(),
949            Some("abc")
950        );
951        assert_eq!(
952            extract_token(&ctx_with_auth(Some("bearer abc"))).as_deref(),
953            Some("abc")
954        );
955        // raw token without prefix is accepted
956        assert_eq!(
957            extract_token(&ctx_with_auth(Some("abc"))).as_deref(),
958            Some("abc")
959        );
960        assert_eq!(extract_token(&ctx_with_auth(None)), None);
961        assert_eq!(extract_token(&ctx_with_auth(Some("Bearer "))), None);
962    }
963
964    #[test]
965    fn test_token_is_active() {
966        assert!(token_is_active(br#"{"active": true, "sub": "u1"}"#));
967        assert!(!token_is_active(br#"{"active": false}"#));
968        assert!(!token_is_active(br#"{"sub": "u1"}"#));
969        assert!(!token_is_active(b"not json"));
970    }
971
972    #[tokio::test]
973    async fn test_missing_token_denied() {
974        let plugin =
975            AuthzCasdoorPlugin::from_config(&stateless_cfg(), &PluginResources::empty()).unwrap();
976        // trailing slash trimmed
977        assert_eq!(plugin.endpoint_addr, "https://casdoor.example.com");
978        // stateless by default
979        assert!(plugin.sealer.is_none());
980        let out = plugin.execute(ctx_with_auth(None)).await.unwrap();
981        assert_eq!(out.port, Some("denied"));
982        assert_eq!(out.context.response.status_code, 403);
983    }
984
985    /// Regression: before the port split, a Casdoor introspection callout
986    /// failure (nothing listening) was folded into the same denial as an
987    /// actual invalid/inactive token. It is a genuine infra failure and must
988    /// stay on `Err`.
989    #[tokio::test]
990    async fn test_introspection_unreachable_stays_on_error_port() {
991        let mut config = stateless_cfg();
992        config.insert(
993            "endpoint_addr".to_string(),
994            serde_json::json!("http://127.0.0.1:1"),
995        );
996        config.insert("timeout".to_string(), serde_json::json!(200));
997        let plugin = AuthzCasdoorPlugin::from_config(&config, &PluginResources::empty()).unwrap();
998        let err = plugin
999            .execute(ctx_with_auth(Some("Bearer tok")))
1000            .await
1001            .unwrap_err();
1002        crate::plugins::util::provider_error::testing::assert_provider_error(
1003            &err,
1004            "AUTHZ_CASDOOR_ERROR",
1005        );
1006    }
1007
1008    /// Minimal one-shot HTTP server that answers any request with a fixed
1009    /// status line and no body. Returns its port.
1010    async fn spawn_status_server(status_line: &'static str) -> u16 {
1011        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1012        let port = listener.local_addr().unwrap().port();
1013        tokio::spawn(async move {
1014            if let Ok((mut stream, _)) = listener.accept().await {
1015                use tokio::io::{AsyncReadExt, AsyncWriteExt};
1016                let mut buf = [0u8; 4096];
1017                let _ = stream.read(&mut buf).await;
1018                let _ = stream
1019                    .write_all(
1020                        format!("HTTP/1.1 {status_line}\r\ncontent-length: 0\r\n\r\n").as_bytes(),
1021                    )
1022                    .await;
1023                let _ = stream.shutdown().await;
1024            }
1025        });
1026        port
1027    }
1028
1029    /// Regression: a non-200 from the introspection endpoint is not an
1030    /// `active: false` token decision (RFC 7662 signals that with `200`); it
1031    /// means the callout itself went wrong (bad client credentials, a
1032    /// fronting proxy down, ...), so it must stay a genuine `Err`, not be
1033    /// folded into `denied`.
1034    #[tokio::test]
1035    async fn test_introspection_non_200_is_callout_error_not_denied() {
1036        let port = spawn_status_server("500 Internal Server Error").await;
1037        let mut config = stateless_cfg();
1038        config.insert(
1039            "endpoint_addr".to_string(),
1040            serde_json::json!(format!("http://127.0.0.1:{port}")),
1041        );
1042        let plugin = AuthzCasdoorPlugin::from_config(&config, &PluginResources::empty()).unwrap();
1043        let err = plugin
1044            .execute(ctx_with_auth(Some("Bearer tok")))
1045            .await
1046            .unwrap_err();
1047        crate::plugins::util::provider_error::testing::assert_provider_error(
1048            &err,
1049            "AUTHZ_CASDOOR_ERROR",
1050        );
1051    }
1052
1053    #[test]
1054    fn test_requires_endpoint_and_credentials() {
1055        assert!(
1056            AuthzCasdoorPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err()
1057        );
1058        let mut config = HashMap::new();
1059        config.insert(
1060            "endpoint_addr".to_string(),
1061            serde_json::json!("https://casdoor"),
1062        );
1063        config.insert("client_id".to_string(), serde_json::json!("id"));
1064        // missing client_secret
1065        assert!(AuthzCasdoorPlugin::from_config(&config, &PluginResources::empty()).is_err());
1066    }
1067
1068    #[test]
1069    fn test_interactive_requires_callback_url() {
1070        // session_secret set but no callback_url ⇒ rejected at load.
1071        let mut config = stateless_cfg();
1072        config.insert("session_secret".to_string(), serde_json::json!("s3cr3t"));
1073        assert!(AuthzCasdoorPlugin::from_config(&config, &PluginResources::empty()).is_err());
1074    }
1075
1076    #[test]
1077    fn test_interactive_config_defaults() {
1078        let p =
1079            AuthzCasdoorPlugin::from_config(&interactive_cfg(), &PluginResources::empty()).unwrap();
1080        assert!(p.sealer.is_some());
1081        assert_eq!(p.cookie_name, "casdoor_session");
1082        assert_eq!(p.flow_cookie_name, "casdoor_session_flow");
1083        assert_eq!(p.cookie_lifetime, 3600);
1084        assert_eq!(p.cookie_path, "/");
1085        assert_eq!(p.scope, "read");
1086        assert_eq!(p.callback_path.as_deref(), Some("/casdoor/callback"));
1087    }
1088
1089    #[test]
1090    fn test_session_cookie_path_configurable_and_validated() {
1091        // A path that covers the callback (/casdoor/callback) is accepted...
1092        let mut config = interactive_cfg();
1093        config.insert(
1094            "session".to_string(),
1095            serde_json::json!({ "secret": "s3cr3t", "cookie": { "path": "/casdoor" } }),
1096        );
1097        let p = AuthzCasdoorPlugin::from_config(&config, &PluginResources::empty()).unwrap();
1098        assert_eq!(p.cookie_path, "/casdoor");
1099
1100        // ...the flat UI key works too...
1101        let mut config = interactive_cfg();
1102        config.insert("session_cookie_path".to_string(), serde_json::json!("/"));
1103        assert!(AuthzCasdoorPlugin::from_config(&config, &PluginResources::empty()).is_ok());
1104
1105        // ...but a path that does NOT cover the callback is rejected at load.
1106        let mut config = interactive_cfg();
1107        config.insert(
1108            "session_cookie_path".to_string(),
1109            serde_json::json!("/other"),
1110        );
1111        let err = AuthzCasdoorPlugin::from_config(&config, &PluginResources::empty())
1112            .err()
1113            .unwrap();
1114        assert!(
1115            err.contains("session.cookie.path"),
1116            "unexpected error: {err}"
1117        );
1118    }
1119
1120    #[test]
1121    fn test_callback_path_of() {
1122        assert_eq!(
1123            callback_path_of("https://app.example.com/casdoor/callback").as_deref(),
1124            Some("/casdoor/callback")
1125        );
1126        assert_eq!(callback_path_of("http://h/cb?x=1").as_deref(), Some("/cb"));
1127        // no path component
1128        assert_eq!(callback_path_of("https://app.example.com"), None);
1129    }
1130
1131    #[test]
1132    fn test_build_authorize_url() {
1133        let url = build_authorize_url(
1134            "https://casdoor.example.com",
1135            "my-client",
1136            "https://app.example.com/casdoor/callback",
1137            "abcd1234",
1138            "read",
1139        );
1140        assert_eq!(
1141            url,
1142            "https://casdoor.example.com/login/oauth/authorize?response_type=code\
1143&client_id=my-client\
1144&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcasdoor%2Fcallback\
1145&state=abcd1234&scope=read"
1146        );
1147    }
1148
1149    #[test]
1150    fn test_access_token_body_and_parse() {
1151        assert_eq!(
1152            access_token_body("the code", "cid", "csecret"),
1153            "grant_type=authorization_code&code=the+code&client_id=cid&client_secret=csecret"
1154        );
1155        assert_eq!(
1156            parse_access_token(br#"{"access_token":"tok","expires_in":3600}"#).unwrap(),
1157            "tok"
1158        );
1159        // expires_in <= 0 ⇒ invalid
1160        assert!(parse_access_token(br#"{"access_token":"tok","expires_in":0}"#).is_err());
1161        // missing access_token ⇒ error
1162        assert!(parse_access_token(br#"{"error":"bad"}"#).is_err());
1163    }
1164
1165    #[test]
1166    fn test_session_seal_open_round_trip() {
1167        let sealer = CookieSealer::new("s3cr3t");
1168        let session = CasdoorSession {
1169            access_token: "tok-123".into(),
1170            client_id: "id".into(),
1171            claims: Some(serde_json::json!({ "sub": "u1", "name": "Alice" })),
1172        };
1173        let payload = serde_json::to_vec(&session).unwrap();
1174        let cookie = sealer.seal(&payload, Duration::from_secs(3600));
1175        let opened = sealer.open(&cookie).unwrap();
1176        let back: CasdoorSession = serde_json::from_slice(&opened).unwrap();
1177        assert_eq!(back.access_token, "tok-123");
1178        assert_eq!(back.client_id, "id");
1179        assert_eq!(back.claims.unwrap().get("sub").unwrap(), "u1");
1180    }
1181
1182    #[test]
1183    fn test_decode_jwt_claims() {
1184        // header.payload.sig where payload = {"sub":"u1"}
1185        let payload = URL_SAFE_NO_PAD.encode(br#"{"sub":"u1","name":"Bob"}"#);
1186        let token = format!("aGVhZGVy.{payload}.c2ln");
1187        let claims = decode_jwt_claims(&token).unwrap();
1188        assert_eq!(claims.get("sub").unwrap(), "u1");
1189        // opaque (non-JWT) token ⇒ None
1190        assert!(decode_jwt_claims("opaque-token").is_none());
1191    }
1192
1193    #[tokio::test]
1194    async fn test_interactive_begin_login_redirects() {
1195        let p =
1196            AuthzCasdoorPlugin::from_config(&interactive_cfg(), &PluginResources::empty()).unwrap();
1197        let out = p.execute(ctx("/protected", HashMap::new())).await.unwrap();
1198        assert_eq!(out.port, Some("redirect"));
1199        assert_eq!(out.context.response.status_code, 302);
1200        let location = &out.context.response.headers.get("location").unwrap()[0];
1201        assert!(
1202            location.starts_with("https://casdoor.example.com/login/oauth/authorize?"),
1203            "{location}"
1204        );
1205        assert!(location.contains("response_type=code"));
1206        // A sealed flow cookie is set.
1207        let set = &out.context.response.headers.get("set-cookie").unwrap()[0];
1208        assert!(set.starts_with("casdoor_session_flow="), "{set}");
1209    }
1210
1211    #[tokio::test]
1212    async fn test_interactive_valid_session_passes() {
1213        let p =
1214            AuthzCasdoorPlugin::from_config(&interactive_cfg(), &PluginResources::empty()).unwrap();
1215        let sealer = CookieSealer::new("s3cr3t");
1216        let session = CasdoorSession {
1217            access_token: "tok-xyz".into(),
1218            client_id: "id".into(),
1219            claims: Some(serde_json::json!({ "sub": "u1" })),
1220        };
1221        let sealed = sealer.seal(
1222            &serde_json::to_vec(&session).unwrap(),
1223            Duration::from_secs(3600),
1224        );
1225
1226        let mut c = ctx("/protected", HashMap::new());
1227        c.request.headers.insert(
1228            "cookie".to_string(),
1229            vec![format!("casdoor_session={}", sealed)],
1230        );
1231
1232        let out = p.execute(c).await.unwrap();
1233        assert_eq!(
1234            out.context.request.headers.get("authorization").unwrap()[0],
1235            "Bearer tok-xyz"
1236        );
1237        assert_eq!(out.context.message.get("user_id").unwrap(), "u1");
1238    }
1239
1240    #[tokio::test]
1241    async fn test_interactive_callback_bad_state_denied() {
1242        let p =
1243            AuthzCasdoorPlugin::from_config(&interactive_cfg(), &PluginResources::empty()).unwrap();
1244        let sealer = CookieSealer::new("s3cr3t");
1245        let flow = CasdoorFlow {
1246            state: "expected".into(),
1247            original_uri: "/home".into(),
1248        };
1249        let sealed = sealer.seal(
1250            &serde_json::to_vec(&flow).unwrap(),
1251            Duration::from_secs(300),
1252        );
1253
1254        let mut query = HashMap::new();
1255        query.insert("code".to_string(), vec!["c".to_string()]);
1256        query.insert("state".to_string(), vec!["WRONG".to_string()]);
1257        let mut c = ctx("/casdoor/callback", query);
1258        c.request.headers.insert(
1259            "cookie".to_string(),
1260            vec![format!("casdoor_session_flow={}", sealed)],
1261        );
1262
1263        let out = p.execute(c).await.unwrap();
1264        assert_eq!(out.port, Some("denied"));
1265        assert_eq!(out.context.response.status_code, 403);
1266    }
1267
1268    /// The callback's code-exchange call to Casdoor is a genuine provider
1269    /// callout; when it is unreachable that must stay `Err`, not be folded
1270    /// into `denied` alongside the state-mismatch check above.
1271    #[tokio::test]
1272    async fn test_interactive_callback_token_exchange_unreachable_stays_on_error_port() {
1273        let mut config = interactive_cfg();
1274        config.insert(
1275            "endpoint_addr".to_string(),
1276            serde_json::json!("http://127.0.0.1:1"),
1277        );
1278        config.insert("timeout".to_string(), serde_json::json!(200));
1279        let p = AuthzCasdoorPlugin::from_config(&config, &PluginResources::empty()).unwrap();
1280
1281        let sealer = CookieSealer::new("s3cr3t");
1282        let flow = CasdoorFlow {
1283            state: "matching".into(),
1284            original_uri: "/home".into(),
1285        };
1286        let sealed = sealer.seal(
1287            &serde_json::to_vec(&flow).unwrap(),
1288            Duration::from_secs(300),
1289        );
1290
1291        let mut query = HashMap::new();
1292        query.insert("code".to_string(), vec!["c".to_string()]);
1293        query.insert("state".to_string(), vec!["matching".to_string()]);
1294        let mut c = ctx("/casdoor/callback", query);
1295        c.request.headers.insert(
1296            "cookie".to_string(),
1297            vec![format!("casdoor_session_flow={}", sealed)],
1298        );
1299
1300        let err = p.execute(c).await.unwrap_err();
1301        crate::plugins::util::provider_error::testing::assert_provider_error(
1302            &err,
1303            "AUTHZ_CASDOOR_ERROR",
1304        );
1305    }
1306
1307    #[test]
1308    fn test_reconstruct_uri() {
1309        let mut query = HashMap::new();
1310        query.insert("a".to_string(), vec!["1".to_string()]);
1311        assert_eq!(reconstruct_uri(&ctx("/p", query)), "/p?a=1");
1312        assert_eq!(reconstruct_uri(&ctx("/p", HashMap::new())), "/p");
1313    }
1314
1315    /// redis storage requires a store name; unknown stores fail at config.
1316    #[test]
1317    fn test_session_storage_redis_requires_store() {
1318        let mut config = interactive_cfg();
1319        config.insert(
1320            "session".to_string(),
1321            serde_json::json!({ "storage": "redis" }),
1322        );
1323        // `.err().unwrap()` (not `unwrap_err()`): the Ok type isn't `Debug`.
1324        let err = AuthzCasdoorPlugin::from_config(&config, &PluginResources::empty())
1325            .err()
1326            .unwrap();
1327        assert!(err.contains("requires 'session.store'"), "{err}");
1328    }
1329
1330    #[cfg(feature = "redis-store")]
1331    fn resources_with_fake_store() -> (Arc<PluginResources>, Arc<crate::sessions::FakeSessionStore>)
1332    {
1333        let fake = Arc::new(crate::sessions::FakeSessionStore::default());
1334        let resources = PluginResources::empty();
1335        resources.stores.store(Arc::new(
1336            crate::stores::StoreRegistry::with_fake_session_store("s1", fake.clone()),
1337        ));
1338        (resources, fake)
1339    }
1340
1341    /// In redis mode a valid session cookie authenticates from the store, and
1342    /// a store outage is a 503 on the error port — never a silent re-login.
1343    #[cfg(feature = "redis-store")]
1344    #[tokio::test]
1345    async fn test_redis_session_read_and_store_outage_503() {
1346        use crate::sessions::SessionStore as _;
1347
1348        let (resources, fake) = resources_with_fake_store();
1349        let mut config = interactive_cfg();
1350        config.insert(
1351            "session".to_string(),
1352            serde_json::json!({ "secret": "s3cr3t", "storage": "redis", "store": "s1" }),
1353        );
1354        let p = AuthzCasdoorPlugin::from_config(&config, &resources).unwrap();
1355
1356        // Establish a session by hand: seal a CasdoorSession, put under an id.
1357        let sealer = CookieSealer::new("s3cr3t");
1358        let session = CasdoorSession {
1359            access_token: "tok-xyz".into(),
1360            client_id: "id".into(),
1361            claims: Some(serde_json::json!({ "sub": "u1" })),
1362        };
1363        let payload = serde_json::to_vec(&session).unwrap();
1364        let sealed = sealer.seal(&payload, Duration::from_secs(3600));
1365        let id = crate::sessions::SessionId::random();
1366        let meta = crate::sessions::SessionMeta {
1367            id: String::new(),
1368            subject: "u1".to_string(),
1369            plugin: "authz-casdoor".to_string(),
1370            policy: String::new(),
1371            route: String::new(),
1372            created_at: 0,
1373            expires_at: 0,
1374        };
1375        fake.put(&id, sealed.as_bytes(), Duration::from_secs(3600), &meta)
1376            .await
1377            .unwrap();
1378
1379        let mut c = ctx("/protected", HashMap::new());
1380        c.request.headers.insert(
1381            "cookie".to_string(),
1382            vec![format!("casdoor_session={}", id.as_str())],
1383        );
1384        let out = p.execute(c).await.unwrap();
1385        assert_eq!(
1386            out.context.request.headers.get("authorization").unwrap()[0],
1387            "Bearer tok-xyz"
1388        );
1389        assert_eq!(out.context.message.get("user_id").unwrap(), "u1");
1390
1391        // Outage: same request, failing store.
1392        fake.fail.store(true, std::sync::atomic::Ordering::Relaxed);
1393        let mut c = ctx("/protected", HashMap::new());
1394        c.request.headers.insert(
1395            "cookie".to_string(),
1396            vec![format!("casdoor_session={}", id.as_str())],
1397        );
1398        let err = p.execute(c).await.unwrap_err();
1399        assert_eq!(err.error.code, "SESSION_STORE_ERROR");
1400        assert_eq!(err.context.response.status_code, 503);
1401    }
1402}