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; anything else denies it with `AUTHZ_CASDOOR_DENIED` (`403`).
11//! - **Interactive (opt-in)** — set `session_secret` (or `session.secret`) to
12//!   turn on the full **OAuth Authorization Code** login flow using the shared
13//!   [encrypted-cookie session primitive](crate::plugins::util::cookie_session).
14//!   Unauthenticated browsers are redirected to Casdoor's authorize URL; the
15//!   callback exchanges the `code` for an access token, which is sealed into an
16//!   encrypted client-side cookie (no server-side session store). See the
17//!   three-branch logic in [`AuthzCasdoorPlugin::execute_interactive`].
18//!
19//! ## Redirect wiring (interactive mode)
20//!
21//! A `302` produced by this node (login redirect, post-callback redirect, or
22//! logout) is returned as an [`Err`] carrying the prepared response with code
23//! `CASDOOR_REDIRECT`, following the same early-exit convention as the
24//! `fault-injection`/`mocking` nodes. **Wire the node's `error` edge to
25//! `client.in`** so the redirect reaches the browser; the `success` edge
26//! carries authenticated requests on to the upstream.
27
28use async_trait::async_trait;
29use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
30use base64::Engine;
31use bytes::Bytes;
32use ring::rand::{SecureRandom, SystemRandom};
33use serde::{Deserialize, Serialize};
34use std::collections::HashMap;
35use std::sync::Arc;
36use std::time::Duration;
37
38use crate::context::{Context, GatewayError};
39use crate::outbound::{OutboundClient, OutboundError, OutboundRequest};
40use crate::plugins::resources::PluginResources;
41use crate::plugins::util::cookie_session::{
42    build_set_cookie, delete_cookie, path_covers, read_cookie, CookieAttrs, CookieSealer, SameSite,
43};
44use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
45
46/// Session payload sealed into the Casdoor session cookie (interactive mode).
47#[derive(Debug, Clone, Serialize, Deserialize)]
48struct CasdoorSession {
49    /// The Casdoor access token minted at the callback.
50    access_token: String,
51    /// The client id this session was issued under (guards cross-config reuse).
52    client_id: String,
53    /// Decoded access-token claims, when the token is a JWT.
54    #[serde(default)]
55    claims: Option<serde_json::Value>,
56}
57
58/// Transient login-flow payload sealed into the short-lived flow cookie.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60struct CasdoorFlow {
61    /// Anti-CSRF `state` echoed back on the callback.
62    state: String,
63    /// URI to return the browser to after login completes.
64    original_uri: String,
65}
66
67/// Validates a Casdoor access token, and in interactive mode runs the SSO flow.
68pub struct AuthzCasdoorPlugin {
69    /// Casdoor server base URL, without a trailing slash.
70    endpoint_addr: String,
71    /// Casdoor application client id.
72    client_id: String,
73    /// Casdoor application client secret.
74    client_secret: String,
75    /// `Authorization: Basic ...` header value built from the client credentials.
76    basic_auth: String,
77    /// TLS certificate verification for the callout.
78    ssl_verify: bool,
79    /// Whole-call timeout for the callout.
80    timeout: Duration,
81    /// When set, interactive SSO login is enabled and this seals/opens cookies.
82    sealer: Option<CookieSealer>,
83    /// Full callback URL registered with Casdoor (the OAuth `redirect_uri`).
84    callback_url: Option<String>,
85    /// Path component of `callback_url`, matched against the request path.
86    callback_path: Option<String>,
87    /// OAuth `scope` requested at the authorize step (default `read`).
88    scope: String,
89    /// Session cookie name (interactive mode).
90    cookie_name: String,
91    /// Transient login-flow cookie name (interactive mode).
92    flow_cookie_name: String,
93    /// `Path` attribute of the session and flow cookies (interactive mode).
94    /// Scope to a subpath (e.g. `/app_a`) for independent per-app sessions;
95    /// must cover `callback_path`. Defaults to `/`.
96    cookie_path: String,
97    /// Session cookie lifetime in seconds (interactive mode).
98    cookie_lifetime: u64,
99    /// Optional logout path; a request to it clears the session cookie.
100    logout_path: Option<String>,
101    /// Randomness source for the anti-CSRF `state`.
102    rng: SystemRandom,
103    /// Shared pooled outbound HTTP client.
104    outbound: Arc<OutboundClient>,
105}
106
107impl AuthzCasdoorPlugin {
108    /// Builds the plugin from node config.
109    ///
110    /// Accepted keys:
111    /// - `endpoint_addr` (string, **required**): Casdoor base URL (a trailing
112    ///   `/` is trimmed).
113    /// - `client_id` (string, **required**): Casdoor application client id.
114    /// - `client_secret` (string, **required**): Casdoor application client
115    ///   secret. Used for HTTP Basic auth on the introspection call (stateless)
116    ///   and the code-exchange call (interactive).
117    /// - `callback_url` (string): OAuth `redirect_uri`. **Required in interactive
118    ///   mode**; accepted-but-unused in stateless mode.
119    /// - `ssl_verify` (bool, default `true`): verify the endpoint's TLS certificate.
120    /// - `timeout` (integer ms, default `3000`): callout timeout.
121    ///
122    /// Interactive-mode keys (a session secret ⇒ interactive login is enabled):
123    /// - `session_secret` (string) or `session.secret` (string): signing/encryption
124    ///   secret for the session and flow cookies. Setting it turns on the SSO flow.
125    /// - `session.cookie.name` (string, default `"casdoor_session"`): session cookie name.
126    /// - `session.cookie.path` (string, default `"/"`): session/flow cookie `Path`;
127    ///   scope to a subpath (e.g. `/app_a`) for independent per-app sessions. Must
128    ///   cover the `callback_url` path (rejected at load otherwise).
129    /// - `session.cookie.lifetime` (u64 seconds, default `3600`): cookie lifetime.
130    /// - `scope` (string, default `"read"`): OAuth scope requested at authorize.
131    /// - `logout_path` (string, optional): request path that clears the session
132    ///   cookie and redirects to `/`.
133    ///
134    /// ```yaml
135    /// - id: authz
136    ///   type: authz-casdoor
137    ///   config:
138    ///     endpoint_addr: https://casdoor.example.com
139    ///     client_id: ${CASDOOR_CLIENT_ID}
140    ///     client_secret: ${CASDOOR_CLIENT_SECRET}
141    ///     callback_url: https://app.example.com/casdoor/callback
142    ///     session_secret: ${CASDOOR_SESSION_SECRET}
143    ///     scope: read
144    /// ```
145    pub fn from_config(
146        config: &HashMap<String, serde_json::Value>,
147        resources: &Arc<PluginResources>,
148    ) -> Result<Self, String> {
149        let endpoint_addr = config
150            .get("endpoint_addr")
151            .and_then(|v| v.as_str())
152            .filter(|s| !s.is_empty())
153            .ok_or_else(|| "authz-casdoor requires 'endpoint_addr'".to_string())?
154            .trim_end_matches('/')
155            .to_string();
156
157        let client_id = config
158            .get("client_id")
159            .and_then(|v| v.as_str())
160            .filter(|s| !s.is_empty())
161            .ok_or_else(|| "authz-casdoor requires 'client_id'".to_string())?
162            .to_string();
163
164        let client_secret = config
165            .get("client_secret")
166            .and_then(|v| v.as_str())
167            .filter(|s| !s.is_empty())
168            .ok_or_else(|| "authz-casdoor requires 'client_secret'".to_string())?
169            .to_string();
170
171        let ssl_verify = config
172            .get("ssl_verify")
173            .and_then(|v| v.as_bool())
174            .unwrap_or(true);
175
176        let timeout_ms = config
177            .get("timeout")
178            .and_then(|v| v.as_u64())
179            .unwrap_or(3000);
180
181        let callback_url = config
182            .get("callback_url")
183            .and_then(|v| v.as_str())
184            .filter(|s| !s.is_empty())
185            .map(|s| s.trim_end_matches('/').to_string());
186        let callback_path = callback_url.as_deref().and_then(callback_path_of);
187
188        // Interactive mode is enabled when a session secret is configured.
189        let sealer = session_secret(config).map(|s| CookieSealer::new(&s));
190        if sealer.is_some() && callback_path.is_none() {
191            return Err(
192                "authz-casdoor interactive mode (session_secret set) requires a 'callback_url' \
193                 with a path component"
194                    .to_string(),
195            );
196        }
197
198        let scope = config
199            .get("scope")
200            .and_then(|v| v.as_str())
201            .filter(|s| !s.is_empty())
202            .unwrap_or("read")
203            .to_string();
204        let cookie_name =
205            session_cookie_str(config, "name").unwrap_or_else(|| "casdoor_session".to_string());
206        let flow_cookie_name = format!("{cookie_name}_flow");
207        let cookie_path = session_cookie_str(config, "path").unwrap_or_else(|| "/".to_string());
208        // The callback must receive the flow/session cookies, so the cookie path
209        // has to cover the callback path (interactive mode only, where a callback
210        // path is guaranteed present by the check above). Otherwise login loops.
211        if let Some(cb) = callback_path.as_deref() {
212            if sealer.is_some() && !path_covers(&cookie_path, cb) {
213                return Err(format!(
214                    "authz-casdoor: session.cookie.path '{}' does not cover the callback_url \
215                     path '{}'; the session cookie would not reach the callback and login \
216                     would loop. Set session.cookie.path to a prefix of the callback path.",
217                    cookie_path, cb
218                ));
219            }
220        }
221        let cookie_lifetime = session_cookie_u64(config, "lifetime").unwrap_or(3_600);
222        let logout_path = config
223            .get("logout_path")
224            .and_then(|v| v.as_str())
225            .filter(|s| !s.is_empty())
226            .map(String::from);
227
228        Ok(Self {
229            basic_auth: basic_auth_header(&client_id, &client_secret),
230            endpoint_addr,
231            client_id,
232            client_secret,
233            ssl_verify,
234            timeout: Duration::from_millis(timeout_ms),
235            sealer,
236            callback_url,
237            callback_path,
238            scope,
239            cookie_name,
240            flow_cookie_name,
241            cookie_path,
242            cookie_lifetime,
243            logout_path,
244            rng: SystemRandom::new(),
245            outbound: resources.outbound.clone(),
246        })
247    }
248
249    /// Builds the 403 denial carrying the context.
250    fn deny(ctx: Context, message: impl Into<String>) -> PluginResult {
251        let mut ctx = ctx;
252        ctx.response.status_code = 403;
253        ctx.response.body = Bytes::from(r#"{"error":"access_denied"}"#);
254        ctx.response.headers.insert(
255            "content-type".to_string(),
256            vec!["application/json".to_string()],
257        );
258        Err(PluginExecutionError {
259            context: ctx,
260            error: GatewayError {
261                node_id: String::new(),
262                code: "AUTHZ_CASDOOR_DENIED".to_string(),
263                message: message.into(),
264                metadata: HashMap::new(),
265            },
266        })
267    }
268
269    /// Builds a `302` early-exit carrying the prepared response. Wire the
270    /// node's **error** edge to `client.in` so this reaches the browser.
271    fn redirect(mut ctx: Context, location: String, set_cookies: Vec<String>) -> PluginResult {
272        ctx.response.status_code = 302;
273        ctx.response
274            .headers
275            .insert("location".to_string(), vec![location]);
276        if !set_cookies.is_empty() {
277            ctx.response
278                .headers
279                .insert("set-cookie".to_string(), set_cookies);
280        }
281        ctx.response.body = Bytes::new();
282        Err(PluginExecutionError {
283            context: ctx,
284            error: GatewayError {
285                node_id: String::new(),
286                code: "CASDOOR_REDIRECT".to_string(),
287                message: "authz-casdoor redirect".to_string(),
288                metadata: HashMap::new(),
289            },
290        })
291    }
292
293    /// Cookie attributes: `HttpOnly`, `SameSite=Lax`, `Secure` only over HTTPS.
294    fn cookie_attrs(&self, ctx: &Context, max_age: u64) -> CookieAttrs<'_> {
295        CookieAttrs {
296            path: &self.cookie_path,
297            max_age: Some(max_age),
298            http_only: true,
299            secure: ctx.request.scheme == "https",
300            same_site: SameSite::Lax,
301        }
302    }
303
304    /// Reads and opens the session cookie, returning the sealed session.
305    fn read_session(&self, ctx: &Context) -> Option<CasdoorSession> {
306        let sealer = self.sealer.as_ref()?;
307        let cookie_header = ctx.request.headers.get("cookie").and_then(|v| v.first())?;
308        let raw = read_cookie(cookie_header, &self.cookie_name)?;
309        let payload = sealer.open(raw).ok()?;
310        serde_json::from_slice(&payload).ok()
311    }
312
313    /// Reads and opens the transient login-flow cookie.
314    fn read_flow(&self, ctx: &Context) -> Option<CasdoorFlow> {
315        let sealer = self.sealer.as_ref()?;
316        let cookie_header = ctx.request.headers.get("cookie").and_then(|v| v.first())?;
317        let raw = read_cookie(cookie_header, &self.flow_cookie_name)?;
318        let payload = sealer.open(raw).ok()?;
319        serde_json::from_slice(&payload).ok()
320    }
321
322    /// Attaches the authenticated identity from a session to the request.
323    fn attach_session(&self, ctx: &mut Context, session: &CasdoorSession) {
324        ctx.request.headers.insert(
325            "authorization".to_string(),
326            vec![format!("Bearer {}", session.access_token)],
327        );
328        if let Some(claims) = &session.claims {
329            if let Some(sub) = claims.get("sub") {
330                ctx.message.insert("user_id".to_string(), sub.clone());
331            }
332            ctx.message.insert("jwt_claims".to_string(), claims.clone());
333        }
334    }
335
336    /// Exchanges an authorization `code` for a Casdoor access token.
337    async fn fetch_access_token(&self, code: &str) -> Result<String, String> {
338        let request = OutboundRequest {
339            method: http::Method::POST,
340            url: format!("{}/api/login/oauth/access_token", self.endpoint_addr),
341            headers: vec![(
342                "content-type".to_string(),
343                "application/x-www-form-urlencoded".to_string(),
344            )],
345            body: Bytes::from(access_token_body(
346                code,
347                &self.client_id,
348                &self.client_secret,
349            )),
350            timeout: self.timeout,
351            ssl_verify: self.ssl_verify,
352            tls: None,
353        };
354        let resp = self
355            .outbound
356            .request(request)
357            .await
358            .map_err(|e| format!("Casdoor token exchange failed: {e}"))?;
359        if resp.status != 200 {
360            return Err(format!(
361                "Casdoor token endpoint returned status {}",
362                resp.status
363            ));
364        }
365        parse_access_token(&resp.body)
366    }
367
368    /// Interactive SSO flow: callback → valid session → begin login.
369    async fn execute_interactive(&self, mut ctx: Context) -> PluginResult {
370        let sealer = self
371            .sealer
372            .as_ref()
373            .expect("execute_interactive only called when a sealer is configured");
374
375        // 0. Logout: clear the session cookie and bounce to "/".
376        if let Some(ref logout_path) = self.logout_path {
377            if &ctx.request.path == logout_path {
378                let del = delete_cookie(&self.cookie_name, &self.cookie_path);
379                return Self::redirect(ctx, "/".to_string(), vec![del]);
380            }
381        }
382
383        // 1. Callback: request path matches callback_url path and carries
384        //    code+state. Validate state, exchange the code, seal a session.
385        if self.is_callback(&ctx) {
386            return self.handle_callback(ctx, sealer).await;
387        }
388
389        // 2. Valid session cookie (for this client_id) → authenticate from it.
390        if let Some(session) = self.read_session(&ctx) {
391            if session.client_id == self.client_id {
392                self.attach_session(&mut ctx, &session);
393                return Ok(PluginOutput {
394                    context: ctx,
395                    named_outputs: HashMap::new(),
396                });
397            }
398        }
399
400        // 3. No session, not a callback → begin login at Casdoor.
401        self.begin_login(ctx, sealer)
402    }
403
404    /// True when the request is the OAuth callback (path + `code` + `state`).
405    fn is_callback(&self, ctx: &Context) -> bool {
406        self.callback_path.as_deref() == Some(ctx.request.path.as_str())
407            && ctx.request.query_params.contains_key("code")
408            && ctx.request.query_params.contains_key("state")
409    }
410
411    /// Handles the OAuth callback: verify state, exchange code, seal a session,
412    /// and redirect to the original URI.
413    async fn handle_callback(&self, ctx: Context, sealer: &CookieSealer) -> PluginResult {
414        let flow = match self.read_flow(&ctx) {
415            Some(f) => f,
416            None => return Self::deny(ctx, "missing or invalid login-flow cookie"),
417        };
418        let state = query_first(&ctx, "state").unwrap_or_default();
419        if state != flow.state {
420            return Self::deny(ctx, "OAuth state mismatch");
421        }
422        let code = match query_first(&ctx, "code") {
423            Some(c) if !c.is_empty() => c,
424            _ => return Self::deny(ctx, "missing authorization code"),
425        };
426
427        let access_token = match self.fetch_access_token(&code).await {
428            Ok(t) => t,
429            Err(e) => return Self::deny(ctx, e),
430        };
431
432        let claims = decode_jwt_claims(&access_token);
433        let session = CasdoorSession {
434            access_token,
435            client_id: self.client_id.clone(),
436            claims,
437        };
438        let payload = serde_json::to_vec(&session).unwrap_or_default();
439        let sealed = sealer.seal(&payload, Duration::from_secs(self.cookie_lifetime));
440        let set_session = build_set_cookie(
441            &self.cookie_name,
442            &sealed,
443            &self.cookie_attrs(&ctx, self.cookie_lifetime),
444        );
445        let del_flow = delete_cookie(&self.flow_cookie_name, &self.cookie_path);
446        Self::redirect(ctx, flow.original_uri, vec![set_session, del_flow])
447    }
448
449    /// Begins interactive login: mint a `state`, stash it plus the original URI
450    /// in a short-lived flow cookie, and redirect to Casdoor's authorize URL.
451    fn begin_login(&self, ctx: Context, sealer: &CookieSealer) -> PluginResult {
452        let state = random_state(&self.rng);
453        let original_uri = reconstruct_uri(&ctx);
454        let flow = CasdoorFlow {
455            state: state.clone(),
456            original_uri,
457        };
458        let payload = serde_json::to_vec(&flow).unwrap_or_default();
459        // Short-lived: the flow cookie only needs to survive the round trip.
460        let sealed = sealer.seal(&payload, Duration::from_secs(300));
461        let set_flow = build_set_cookie(
462            &self.flow_cookie_name,
463            &sealed,
464            &self.cookie_attrs(&ctx, 300),
465        );
466
467        let callback = self.callback_url.as_deref().unwrap_or("");
468        let authorize = build_authorize_url(
469            &self.endpoint_addr,
470            &self.client_id,
471            callback,
472            &state,
473            &self.scope,
474        );
475        Self::redirect(ctx, authorize, vec![set_flow])
476    }
477
478    /// Stateless bearer-token validation via introspection (default behavior).
479    async fn execute_stateless(&self, ctx: Context) -> PluginResult {
480        let token = match extract_token(&ctx) {
481            Some(t) => t,
482            None => return Self::deny(ctx, "missing Casdoor access token"),
483        };
484
485        let request = OutboundRequest {
486            method: http::Method::POST,
487            url: introspect_url(&self.endpoint_addr),
488            headers: vec![
489                (
490                    "content-type".to_string(),
491                    "application/x-www-form-urlencoded".to_string(),
492                ),
493                ("authorization".to_string(), self.basic_auth.clone()),
494            ],
495            body: Bytes::from(introspect_body(&token)),
496            timeout: self.timeout,
497            ssl_verify: self.ssl_verify,
498            tls: None,
499        };
500
501        match self.outbound.request(request).await {
502            Ok(resp) if resp.status == 200 && token_is_active(&resp.body) => Ok(PluginOutput {
503                context: ctx,
504                named_outputs: HashMap::new(),
505            }),
506            Ok(resp) => Self::deny(
507                ctx,
508                format!(
509                    "Casdoor token inactive or rejected (status {})",
510                    resp.status
511                ),
512            ),
513            Err(e) => {
514                let detail = match &e {
515                    OutboundError::Timeout(d) => format!("Casdoor request timed out after {d:?}"),
516                    OutboundError::InvalidRequest(m) => format!("invalid Casdoor request: {m}"),
517                    OutboundError::Transport(m) => format!("Casdoor request failed: {m}"),
518                };
519                Self::deny(ctx, detail)
520            }
521        }
522    }
523}
524
525/// Reads the session secret from `session_secret` or nested `session.secret`.
526fn session_secret(config: &HashMap<String, serde_json::Value>) -> Option<String> {
527    config
528        .get("session_secret")
529        .and_then(|v| v.as_str())
530        .or_else(|| {
531            config
532                .get("session")
533                .and_then(|s| s.get("secret"))
534                .and_then(|v| v.as_str())
535        })
536        .filter(|s| !s.is_empty())
537        .map(String::from)
538}
539
540/// Reads a string field from nested `session.cookie.<key>`, falling back to the
541/// flat `session_cookie_<key>` form (used by the UI schema).
542fn session_cookie_str(config: &HashMap<String, serde_json::Value>, key: &str) -> Option<String> {
543    config
544        .get("session")
545        .and_then(|s| s.get("cookie"))
546        .and_then(|c| c.get(key))
547        .or_else(|| config.get(&format!("session_cookie_{key}")))
548        .and_then(|v| v.as_str())
549        .filter(|s| !s.is_empty())
550        .map(String::from)
551}
552
553/// Reads a u64 field from nested `session.cookie.<key>`, falling back to the
554/// flat `session_cookie_<key>` form (used by the UI schema).
555fn session_cookie_u64(config: &HashMap<String, serde_json::Value>, key: &str) -> Option<u64> {
556    config
557        .get("session")
558        .and_then(|s| s.get("cookie"))
559        .and_then(|c| c.get(key))
560        .or_else(|| config.get(&format!("session_cookie_{key}")))
561        .and_then(|v| v.as_u64())
562}
563
564/// Extracts the path component of a callback URL (`https://h/p?x` → `/p`).
565/// Mirrors the APISIX regex `.+//[^/]+(/.*)`.
566fn callback_path_of(url: &str) -> Option<String> {
567    let after_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url);
568    let slash = after_scheme.find('/')?;
569    let path = &after_scheme[slash..];
570    // Strip query/fragment; the request path never carries them.
571    let path = path.split(['?', '#']).next().unwrap_or(path);
572    if path.is_empty() {
573        None
574    } else {
575        Some(path.to_string())
576    }
577}
578
579/// Builds the `Authorization: Basic <base64(client_id:client_secret)>` value.
580fn basic_auth_header(client_id: &str, client_secret: &str) -> String {
581    let raw = format!("{client_id}:{client_secret}");
582    format!("Basic {}", STANDARD.encode(raw.as_bytes()))
583}
584
585/// The Casdoor OAuth token-introspection endpoint for a base URL.
586fn introspect_url(endpoint_addr: &str) -> String {
587    format!("{endpoint_addr}/api/login/oauth/introspect")
588}
589
590/// Builds the Casdoor authorize URL that begins the login handshake.
591fn build_authorize_url(
592    endpoint_addr: &str,
593    client_id: &str,
594    callback_url: &str,
595    state: &str,
596    scope: &str,
597) -> String {
598    format!(
599        "{}/login/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&scope={}",
600        endpoint_addr,
601        form_encode(client_id),
602        form_encode(callback_url),
603        form_encode(state),
604        form_encode(scope),
605    )
606}
607
608/// Encodes the code-exchange request body.
609fn access_token_body(code: &str, client_id: &str, client_secret: &str) -> String {
610    format!(
611        "grant_type=authorization_code&code={}&client_id={}&client_secret={}",
612        form_encode(code),
613        form_encode(client_id),
614        form_encode(client_secret),
615    )
616}
617
618/// Parses the token endpoint's JSON, returning the `access_token` when the
619/// reply is a valid, unexpired grant (`expires_in > 0`, per Casdoor).
620fn parse_access_token(body: &[u8]) -> Result<String, String> {
621    let data: serde_json::Value =
622        serde_json::from_slice(body).map_err(|e| format!("failed to parse Casdoor token: {e}"))?;
623    let token = data
624        .get("access_token")
625        .and_then(|v| v.as_str())
626        .filter(|s| !s.is_empty())
627        .ok_or_else(|| "Casdoor token response missing access_token".to_string())?;
628    // Casdoor signals an invalid token with expires_in <= 0.
629    if let Some(expires) = data.get("expires_in") {
630        let secs = expires
631            .as_i64()
632            .or_else(|| expires.as_str().and_then(|s| s.parse().ok()));
633        if matches!(secs, Some(n) if n <= 0) {
634            return Err("Casdoor returned an expired/invalid access_token".to_string());
635        }
636    }
637    Ok(token.to_string())
638}
639
640/// Decodes a JWT's claim set (middle segment) without verifying the signature.
641/// The token came directly from Casdoor's token endpoint over TLS, so it is
642/// trusted here; the claims are only used to surface identity to the upstream.
643fn decode_jwt_claims(token: &str) -> Option<serde_json::Value> {
644    let mut parts = token.split('.');
645    let _header = parts.next()?;
646    let payload = parts.next()?;
647    let bytes = URL_SAFE_NO_PAD.decode(payload).ok()?;
648    let value: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
649    if value.is_object() {
650        Some(value)
651    } else {
652        None
653    }
654}
655
656/// Generates a random anti-CSRF `state` (128 bits, hex-encoded).
657fn random_state(rng: &SystemRandom) -> String {
658    let mut bytes = [0u8; 16];
659    rng.fill(&mut bytes).expect("system RNG must produce state");
660    bytes.iter().map(|b| format!("{b:02x}")).collect()
661}
662
663/// Reconstructs the request URI (`path` plus a best-effort query string) so the
664/// browser can be returned there after login.
665fn reconstruct_uri(ctx: &Context) -> String {
666    let mut uri = ctx.request.path.clone();
667    if !ctx.request.query_params.is_empty() {
668        let mut pairs: Vec<String> = Vec::new();
669        for (k, values) in &ctx.request.query_params {
670            for v in values {
671                if v.is_empty() {
672                    pairs.push(k.clone());
673                } else {
674                    pairs.push(format!("{k}={v}"));
675                }
676            }
677        }
678        uri.push('?');
679        uri.push_str(&pairs.join("&"));
680    }
681    uri
682}
683
684/// The first value of a query parameter.
685fn query_first(ctx: &Context, key: &str) -> Option<String> {
686    ctx.request
687        .query_params
688        .get(key)
689        .and_then(|v| v.first())
690        .cloned()
691}
692
693/// Extracts the raw bearer token (without the `Bearer ` prefix) from the
694/// `Authorization` header.
695fn extract_token(ctx: &Context) -> Option<String> {
696    let raw = ctx
697        .request
698        .headers
699        .get("authorization")
700        .and_then(|v| v.first())?
701        .as_str();
702    let stripped = raw
703        .strip_prefix("Bearer ")
704        .or_else(|| raw.strip_prefix("bearer "))
705        .unwrap_or(raw);
706    let token = stripped.trim();
707    if token.is_empty() {
708        None
709    } else {
710        Some(token.to_string())
711    }
712}
713
714/// Encodes the introspection request body (`token` + `token_type_hint`).
715fn introspect_body(token: &str) -> String {
716    format!("token={}&token_type_hint=access_token", form_encode(token))
717}
718
719/// Percent-encodes a value for `application/x-www-form-urlencoded` bodies.
720fn form_encode(s: &str) -> String {
721    let mut out = String::with_capacity(s.len());
722    for &b in s.as_bytes() {
723        match b {
724            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
725                out.push(b as char)
726            }
727            b' ' => out.push('+'),
728            _ => out.push_str(&format!("%{b:02X}")),
729        }
730    }
731    out
732}
733
734/// Parses an RFC 7662 introspection response, returning the `active` flag.
735/// A missing/false/non-boolean `active` is treated as inactive.
736fn token_is_active(body: &[u8]) -> bool {
737    serde_json::from_slice::<serde_json::Value>(body)
738        .ok()
739        .and_then(|v| v.get("active").and_then(|a| a.as_bool()))
740        .unwrap_or(false)
741}
742
743#[async_trait]
744impl Plugin for AuthzCasdoorPlugin {
745    fn plugin_type(&self) -> &str {
746        "authz-casdoor"
747    }
748
749    async fn execute(
750        &self,
751        ctx: Context,
752        _named_inputs: &HashMap<String, serde_json::Value>,
753    ) -> PluginResult {
754        if self.sealer.is_some() {
755            self.execute_interactive(ctx).await
756        } else {
757            self.execute_stateless(ctx).await
758        }
759    }
760}
761
762#[cfg(test)]
763mod tests {
764    use super::*;
765    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
766
767    fn ctx_with_auth(auth: Option<&str>) -> Context {
768        let mut headers = HashMap::new();
769        if let Some(a) = auth {
770            headers.insert("authorization".to_string(), vec![a.to_string()]);
771        }
772        Context {
773            request: GatewayRequest {
774                method: "GET".to_string(),
775                path: "/data".to_string(),
776                host: "h".to_string(),
777                scheme: "http".to_string(),
778                headers,
779                query_params: HashMap::new(),
780                body: Bytes::new(),
781                remote_addr: "1.2.3.4:5".to_string(),
782                protocol: Protocol::Http1,
783            },
784            response: GatewayResponse {
785                status_code: 0,
786                headers: HashMap::new(),
787                body: Bytes::new(),
788            },
789            message: HashMap::new(),
790            errors: Vec::new(),
791        }
792    }
793
794    fn ctx(path: &str, query: HashMap<String, Vec<String>>) -> Context {
795        Context {
796            request: GatewayRequest {
797                method: "GET".to_string(),
798                path: path.to_string(),
799                host: "app.example.com".to_string(),
800                scheme: "https".to_string(),
801                headers: HashMap::new(),
802                query_params: query,
803                body: Bytes::new(),
804                remote_addr: "1.2.3.4:5".to_string(),
805                protocol: Protocol::Http1,
806            },
807            response: GatewayResponse {
808                status_code: 0,
809                headers: HashMap::new(),
810                body: Bytes::new(),
811            },
812            message: HashMap::new(),
813            errors: Vec::new(),
814        }
815    }
816
817    fn stateless_cfg() -> HashMap<String, serde_json::Value> {
818        let mut config = HashMap::new();
819        config.insert(
820            "endpoint_addr".to_string(),
821            serde_json::json!("https://casdoor.example.com/"),
822        );
823        config.insert("client_id".to_string(), serde_json::json!("id"));
824        config.insert("client_secret".to_string(), serde_json::json!("secret"));
825        config
826    }
827
828    fn interactive_cfg() -> HashMap<String, serde_json::Value> {
829        let mut config = stateless_cfg();
830        config.insert(
831            "callback_url".to_string(),
832            serde_json::json!("https://app.example.com/casdoor/callback"),
833        );
834        config.insert("session_secret".to_string(), serde_json::json!("s3cr3t"));
835        config
836    }
837
838    #[test]
839    fn test_basic_auth_header() {
840        // base64("id:secret") = aWQ6c2VjcmV0
841        assert_eq!(basic_auth_header("id", "secret"), "Basic aWQ6c2VjcmV0");
842    }
843
844    #[test]
845    fn test_introspect_url_and_body() {
846        assert_eq!(
847            introspect_url("https://casdoor.example.com"),
848            "https://casdoor.example.com/api/login/oauth/introspect"
849        );
850        assert_eq!(
851            introspect_body("abc.def"),
852            "token=abc.def&token_type_hint=access_token"
853        );
854    }
855
856    #[test]
857    fn test_extract_token() {
858        assert_eq!(
859            extract_token(&ctx_with_auth(Some("Bearer abc"))).as_deref(),
860            Some("abc")
861        );
862        assert_eq!(
863            extract_token(&ctx_with_auth(Some("bearer abc"))).as_deref(),
864            Some("abc")
865        );
866        // raw token without prefix is accepted
867        assert_eq!(
868            extract_token(&ctx_with_auth(Some("abc"))).as_deref(),
869            Some("abc")
870        );
871        assert_eq!(extract_token(&ctx_with_auth(None)), None);
872        assert_eq!(extract_token(&ctx_with_auth(Some("Bearer "))), None);
873    }
874
875    #[test]
876    fn test_token_is_active() {
877        assert!(token_is_active(br#"{"active": true, "sub": "u1"}"#));
878        assert!(!token_is_active(br#"{"active": false}"#));
879        assert!(!token_is_active(br#"{"sub": "u1"}"#));
880        assert!(!token_is_active(b"not json"));
881    }
882
883    #[tokio::test]
884    async fn test_missing_token_denied() {
885        let plugin =
886            AuthzCasdoorPlugin::from_config(&stateless_cfg(), &PluginResources::empty()).unwrap();
887        // trailing slash trimmed
888        assert_eq!(plugin.endpoint_addr, "https://casdoor.example.com");
889        // stateless by default
890        assert!(plugin.sealer.is_none());
891        let err = plugin
892            .execute(ctx_with_auth(None), &HashMap::new())
893            .await
894            .unwrap_err();
895        assert_eq!(err.error.code, "AUTHZ_CASDOOR_DENIED");
896        assert_eq!(err.context.response.status_code, 403);
897    }
898
899    #[test]
900    fn test_requires_endpoint_and_credentials() {
901        assert!(
902            AuthzCasdoorPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err()
903        );
904        let mut config = HashMap::new();
905        config.insert(
906            "endpoint_addr".to_string(),
907            serde_json::json!("https://casdoor"),
908        );
909        config.insert("client_id".to_string(), serde_json::json!("id"));
910        // missing client_secret
911        assert!(AuthzCasdoorPlugin::from_config(&config, &PluginResources::empty()).is_err());
912    }
913
914    #[test]
915    fn test_interactive_requires_callback_url() {
916        // session_secret set but no callback_url ⇒ rejected at load.
917        let mut config = stateless_cfg();
918        config.insert("session_secret".to_string(), serde_json::json!("s3cr3t"));
919        assert!(AuthzCasdoorPlugin::from_config(&config, &PluginResources::empty()).is_err());
920    }
921
922    #[test]
923    fn test_interactive_config_defaults() {
924        let p =
925            AuthzCasdoorPlugin::from_config(&interactive_cfg(), &PluginResources::empty()).unwrap();
926        assert!(p.sealer.is_some());
927        assert_eq!(p.cookie_name, "casdoor_session");
928        assert_eq!(p.flow_cookie_name, "casdoor_session_flow");
929        assert_eq!(p.cookie_lifetime, 3600);
930        assert_eq!(p.cookie_path, "/");
931        assert_eq!(p.scope, "read");
932        assert_eq!(p.callback_path.as_deref(), Some("/casdoor/callback"));
933    }
934
935    #[test]
936    fn test_session_cookie_path_configurable_and_validated() {
937        // A path that covers the callback (/casdoor/callback) is accepted...
938        let mut config = interactive_cfg();
939        config.insert(
940            "session".to_string(),
941            serde_json::json!({ "secret": "s3cr3t", "cookie": { "path": "/casdoor" } }),
942        );
943        let p = AuthzCasdoorPlugin::from_config(&config, &PluginResources::empty()).unwrap();
944        assert_eq!(p.cookie_path, "/casdoor");
945
946        // ...the flat UI key works too...
947        let mut config = interactive_cfg();
948        config.insert("session_cookie_path".to_string(), serde_json::json!("/"));
949        assert!(AuthzCasdoorPlugin::from_config(&config, &PluginResources::empty()).is_ok());
950
951        // ...but a path that does NOT cover the callback is rejected at load.
952        let mut config = interactive_cfg();
953        config.insert(
954            "session_cookie_path".to_string(),
955            serde_json::json!("/other"),
956        );
957        let err = AuthzCasdoorPlugin::from_config(&config, &PluginResources::empty())
958            .err()
959            .unwrap();
960        assert!(
961            err.contains("session.cookie.path"),
962            "unexpected error: {err}"
963        );
964    }
965
966    #[test]
967    fn test_callback_path_of() {
968        assert_eq!(
969            callback_path_of("https://app.example.com/casdoor/callback").as_deref(),
970            Some("/casdoor/callback")
971        );
972        assert_eq!(callback_path_of("http://h/cb?x=1").as_deref(), Some("/cb"));
973        // no path component
974        assert_eq!(callback_path_of("https://app.example.com"), None);
975    }
976
977    #[test]
978    fn test_build_authorize_url() {
979        let url = build_authorize_url(
980            "https://casdoor.example.com",
981            "my-client",
982            "https://app.example.com/casdoor/callback",
983            "abcd1234",
984            "read",
985        );
986        assert_eq!(
987            url,
988            "https://casdoor.example.com/login/oauth/authorize?response_type=code\
989&client_id=my-client\
990&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcasdoor%2Fcallback\
991&state=abcd1234&scope=read"
992        );
993    }
994
995    #[test]
996    fn test_access_token_body_and_parse() {
997        assert_eq!(
998            access_token_body("the code", "cid", "csecret"),
999            "grant_type=authorization_code&code=the+code&client_id=cid&client_secret=csecret"
1000        );
1001        assert_eq!(
1002            parse_access_token(br#"{"access_token":"tok","expires_in":3600}"#).unwrap(),
1003            "tok"
1004        );
1005        // expires_in <= 0 ⇒ invalid
1006        assert!(parse_access_token(br#"{"access_token":"tok","expires_in":0}"#).is_err());
1007        // missing access_token ⇒ error
1008        assert!(parse_access_token(br#"{"error":"bad"}"#).is_err());
1009    }
1010
1011    #[test]
1012    fn test_session_seal_open_round_trip() {
1013        let sealer = CookieSealer::new("s3cr3t");
1014        let session = CasdoorSession {
1015            access_token: "tok-123".into(),
1016            client_id: "id".into(),
1017            claims: Some(serde_json::json!({ "sub": "u1", "name": "Alice" })),
1018        };
1019        let payload = serde_json::to_vec(&session).unwrap();
1020        let cookie = sealer.seal(&payload, Duration::from_secs(3600));
1021        let opened = sealer.open(&cookie).unwrap();
1022        let back: CasdoorSession = serde_json::from_slice(&opened).unwrap();
1023        assert_eq!(back.access_token, "tok-123");
1024        assert_eq!(back.client_id, "id");
1025        assert_eq!(back.claims.unwrap().get("sub").unwrap(), "u1");
1026    }
1027
1028    #[test]
1029    fn test_decode_jwt_claims() {
1030        // header.payload.sig where payload = {"sub":"u1"}
1031        let payload = URL_SAFE_NO_PAD.encode(br#"{"sub":"u1","name":"Bob"}"#);
1032        let token = format!("aGVhZGVy.{payload}.c2ln");
1033        let claims = decode_jwt_claims(&token).unwrap();
1034        assert_eq!(claims.get("sub").unwrap(), "u1");
1035        // opaque (non-JWT) token ⇒ None
1036        assert!(decode_jwt_claims("opaque-token").is_none());
1037    }
1038
1039    #[tokio::test]
1040    async fn test_interactive_begin_login_redirects() {
1041        let p =
1042            AuthzCasdoorPlugin::from_config(&interactive_cfg(), &PluginResources::empty()).unwrap();
1043        let err = p
1044            .execute(ctx("/protected", HashMap::new()), &HashMap::new())
1045            .await
1046            .unwrap_err();
1047        assert_eq!(err.error.code, "CASDOOR_REDIRECT");
1048        assert_eq!(err.context.response.status_code, 302);
1049        let location = &err.context.response.headers.get("location").unwrap()[0];
1050        assert!(
1051            location.starts_with("https://casdoor.example.com/login/oauth/authorize?"),
1052            "{location}"
1053        );
1054        assert!(location.contains("response_type=code"));
1055        // A sealed flow cookie is set.
1056        let set = &err.context.response.headers.get("set-cookie").unwrap()[0];
1057        assert!(set.starts_with("casdoor_session_flow="), "{set}");
1058    }
1059
1060    #[tokio::test]
1061    async fn test_interactive_valid_session_passes() {
1062        let p =
1063            AuthzCasdoorPlugin::from_config(&interactive_cfg(), &PluginResources::empty()).unwrap();
1064        let sealer = CookieSealer::new("s3cr3t");
1065        let session = CasdoorSession {
1066            access_token: "tok-xyz".into(),
1067            client_id: "id".into(),
1068            claims: Some(serde_json::json!({ "sub": "u1" })),
1069        };
1070        let sealed = sealer.seal(
1071            &serde_json::to_vec(&session).unwrap(),
1072            Duration::from_secs(3600),
1073        );
1074
1075        let mut c = ctx("/protected", HashMap::new());
1076        c.request.headers.insert(
1077            "cookie".to_string(),
1078            vec![format!("casdoor_session={}", sealed)],
1079        );
1080
1081        let out = p.execute(c, &HashMap::new()).await.unwrap();
1082        assert_eq!(
1083            out.context.request.headers.get("authorization").unwrap()[0],
1084            "Bearer tok-xyz"
1085        );
1086        assert_eq!(out.context.message.get("user_id").unwrap(), "u1");
1087    }
1088
1089    #[tokio::test]
1090    async fn test_interactive_callback_bad_state_denied() {
1091        let p =
1092            AuthzCasdoorPlugin::from_config(&interactive_cfg(), &PluginResources::empty()).unwrap();
1093        let sealer = CookieSealer::new("s3cr3t");
1094        let flow = CasdoorFlow {
1095            state: "expected".into(),
1096            original_uri: "/home".into(),
1097        };
1098        let sealed = sealer.seal(
1099            &serde_json::to_vec(&flow).unwrap(),
1100            Duration::from_secs(300),
1101        );
1102
1103        let mut query = HashMap::new();
1104        query.insert("code".to_string(), vec!["c".to_string()]);
1105        query.insert("state".to_string(), vec!["WRONG".to_string()]);
1106        let mut c = ctx("/casdoor/callback", query);
1107        c.request.headers.insert(
1108            "cookie".to_string(),
1109            vec![format!("casdoor_session_flow={}", sealed)],
1110        );
1111
1112        let err = p.execute(c, &HashMap::new()).await.unwrap_err();
1113        assert_eq!(err.error.code, "AUTHZ_CASDOOR_DENIED");
1114        assert_eq!(err.context.response.status_code, 403);
1115    }
1116
1117    #[test]
1118    fn test_reconstruct_uri() {
1119        let mut query = HashMap::new();
1120        query.insert("a".to_string(), vec!["1".to_string()]);
1121        assert_eq!(reconstruct_uri(&ctx("/p", query)), "/p?a=1");
1122        assert_eq!(reconstruct_uri(&ctx("/p", HashMap::new())), "/p");
1123    }
1124}