1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
48struct CasdoorSession {
49 access_token: String,
51 client_id: String,
53 #[serde(default)]
55 claims: Option<serde_json::Value>,
56}
57
58#[derive(Debug, Clone, Serialize, Deserialize)]
60struct CasdoorFlow {
61 state: String,
63 original_uri: String,
65}
66
67pub struct AuthzCasdoorPlugin {
69 endpoint_addr: String,
71 client_id: String,
73 client_secret: String,
75 basic_auth: String,
77 ssl_verify: bool,
79 timeout: Duration,
81 sealer: Option<CookieSealer>,
83 callback_url: Option<String>,
85 callback_path: Option<String>,
87 scope: String,
89 cookie_name: String,
91 flow_cookie_name: String,
93 cookie_path: String,
97 cookie_lifetime: u64,
99 logout_path: Option<String>,
101 rng: SystemRandom,
103 outbound: Arc<OutboundClient>,
105}
106
107impl AuthzCasdoorPlugin {
108 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 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 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 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 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 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 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 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 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 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 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 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 if self.is_callback(&ctx) {
386 return self.handle_callback(ctx, sealer).await;
387 }
388
389 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 self.begin_login(ctx, sealer)
402 }
403
404 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 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 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 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 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
525fn 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
540fn 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
553fn 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
564fn 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 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
579fn 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
585fn introspect_url(endpoint_addr: &str) -> String {
587 format!("{endpoint_addr}/api/login/oauth/introspect")
588}
589
590fn 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
608fn 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
618fn 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 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
640fn 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
656fn 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
663fn 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
684fn 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
693fn 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
714fn introspect_body(token: &str) -> String {
716 format!("token={}&token_type_hint=access_token", form_encode(token))
717}
718
719fn 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
734fn 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 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 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 assert_eq!(plugin.endpoint_addr, "https://casdoor.example.com");
889 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 assert!(AuthzCasdoorPlugin::from_config(&config, &PluginResources::empty()).is_err());
912 }
913
914 #[test]
915 fn test_interactive_requires_callback_url() {
916 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 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 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 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 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 assert!(parse_access_token(br#"{"access_token":"tok","expires_in":0}"#).is_err());
1007 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 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 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 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}