1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
56struct CasdoorSession {
57 access_token: String,
59 client_id: String,
61 #[serde(default)]
63 claims: Option<serde_json::Value>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68struct CasdoorFlow {
69 state: String,
71 original_uri: String,
73}
74
75pub struct AuthzCasdoorPlugin {
77 endpoint_addr: String,
79 client_id: String,
81 client_secret: String,
83 basic_auth: String,
85 ssl_verify: bool,
87 timeout: Duration,
89 sealer: Option<CookieSealer>,
91 callback_url: Option<String>,
93 callback_path: Option<String>,
95 scope: String,
97 cookie_name: String,
99 flow_cookie_name: String,
101 cookie_path: String,
105 cookie_lifetime: u64,
107 logout_path: Option<String>,
109 backend: SessionBackend,
113 rng: SystemRandom,
115 outbound: Arc<OutboundClient>,
117}
118
119impl AuthzCasdoorPlugin {
120 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 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 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 fn deny(ctx: Context, message: impl Into<String>) -> PluginResult {
274 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 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 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 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 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 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 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 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 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 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 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 if self.is_callback(&ctx) {
457 return self.handle_callback(ctx, sealer).await;
458 }
459
460 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 self.begin_login(ctx, sealer)
472 }
473
474 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 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 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 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 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 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 Ok(resp) if resp.status == 200 => Self::deny(ctx, "Casdoor token inactive"),
596 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
616fn 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
631fn 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
644fn 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
655fn 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 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
670fn 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
676fn introspect_url(endpoint_addr: &str) -> String {
678 format!("{endpoint_addr}/api/login/oauth/introspect")
679}
680
681fn 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
699fn 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
709fn 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 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
731fn 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
747fn 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
754fn 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
775fn 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
784fn 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
805fn introspect_body(token: &str) -> String {
807 format!("token={}&token_type_hint=access_token", form_encode(token))
808}
809
810fn 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
825fn 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 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 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 assert_eq!(plugin.endpoint_addr, "https://casdoor.example.com");
978 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 #[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 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 #[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 assert!(AuthzCasdoorPlugin::from_config(&config, &PluginResources::empty()).is_err());
1066 }
1067
1068 #[test]
1069 fn test_interactive_requires_callback_url() {
1070 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 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 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 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 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 assert!(parse_access_token(br#"{"access_token":"tok","expires_in":0}"#).is_err());
1161 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 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 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 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 #[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 #[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 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 #[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 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 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}