1use async_trait::async_trait;
57use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
58use base64::Engine;
59use bytes::Bytes;
60use std::collections::HashMap;
61use std::sync::Arc;
62use std::time::{Duration, SystemTime, UNIX_EPOCH};
63
64use crate::context::{Context, GatewayError};
65use crate::outbound::{OutboundRequest, OutboundResponse};
66use crate::plugins::resources::PluginResources;
67use crate::plugins::util::cookie_session::{read_cookie, CookieAttrs, CookieSealer, SameSite};
68use crate::plugins::util::server_session::{self, SessionBackend};
69use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
70use crate::sessions::StoreError;
71
72const DEFAULT_TOKEN_URL: &str = "https://open.feishu.cn/open-apis/authen/v2/oauth/token";
73const DEFAULT_USERINFO_URL: &str = "https://open.feishu.cn/open-apis/authen/v1/user_info";
74
75#[derive(Debug)]
79enum FeishuError {
80 Unauthorized(String),
81 Upstream(String),
82}
83
84impl FeishuError {
85 fn message(&self) -> &str {
86 match self {
87 FeishuError::Unauthorized(m) | FeishuError::Upstream(m) => m,
88 }
89 }
90}
91
92#[derive(serde::Serialize, serde::Deserialize)]
96struct FeishuSessionData {
97 userinfo: serde_json::Value,
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 access_token: Option<String>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 access_token_expires_at: Option<u64>,
102}
103
104struct FeishuSession {
106 sealer: CookieSealer,
107 backend: SessionBackend,
108 cookie_name: String,
109 cookie_path: String,
110 cookie_lifetime: u64,
111 redirect_uri: String,
112}
113
114pub struct FeishuAuthPlugin {
117 app_id: String,
118 app_secret: String,
119 auth_redirect_uri: String,
120 code_header: String,
121 code_query: String,
122 token_url: String,
123 userinfo_url: String,
124 set_userinfo_header: bool,
125 timeout: Duration,
126 ssl_verify: bool,
127 resources: Arc<PluginResources>,
128 session: Option<FeishuSession>,
131}
132
133impl FeishuAuthPlugin {
134 pub fn from_config(
181 config: &HashMap<String, serde_json::Value>,
182 resources: &Arc<PluginResources>,
183 ) -> Result<Self, String> {
184 let app_id = require_string(config, "app_id")?;
185 let app_secret = require_string(config, "app_secret")?;
186 let auth_redirect_uri = require_string(config, "auth_redirect_uri")?;
187
188 let code_header = config
189 .get("code_header")
190 .and_then(|v| v.as_str())
191 .unwrap_or("X-Feishu-Code")
192 .to_lowercase();
193 let code_query = config
194 .get("code_query")
195 .and_then(|v| v.as_str())
196 .unwrap_or("code")
197 .to_string();
198 let token_url = config
199 .get("access_token_url")
200 .and_then(|v| v.as_str())
201 .unwrap_or(DEFAULT_TOKEN_URL)
202 .to_string();
203 let userinfo_url = config
204 .get("userinfo_url")
205 .and_then(|v| v.as_str())
206 .unwrap_or(DEFAULT_USERINFO_URL)
207 .to_string();
208 let set_userinfo_header = config
209 .get("set_userinfo_header")
210 .and_then(|v| v.as_bool())
211 .unwrap_or(true);
212 let timeout = Duration::from_millis(
213 config
214 .get("timeout")
215 .and_then(|v| v.as_u64())
216 .unwrap_or(6000),
217 );
218 let ssl_verify = config
219 .get("ssl_verify")
220 .and_then(|v| v.as_bool())
221 .unwrap_or(true);
222
223 let session = match session_secret(config) {
224 Some(secret) => {
225 let redirect_uri = require_string(config, "redirect_uri")?;
226 let sealer = CookieSealer::new(&secret);
227 let cookie_name = session_cookie_str(config, "name")
228 .unwrap_or_else(|| "feishu_session".to_string());
229 let cookie_path =
230 session_cookie_str(config, "path").unwrap_or_else(|| "/".to_string());
231 let cookie_lifetime = session_cookie_u64(config, "lifetime").unwrap_or(86_400);
232 let backend = server_session::parse_backend(config, resources, "feishu-auth")?;
233 Some(FeishuSession {
234 sealer,
235 backend,
236 cookie_name,
237 cookie_path,
238 cookie_lifetime,
239 redirect_uri,
240 })
241 }
242 None => None,
243 };
244
245 Ok(Self {
246 app_id,
247 app_secret,
248 auth_redirect_uri,
249 code_header,
250 code_query,
251 token_url,
252 userinfo_url,
253 set_userinfo_header,
254 timeout,
255 ssl_verify,
256 resources: resources.clone(),
257 session,
258 })
259 }
260
261 fn extract_code(&self, ctx: &Context) -> Option<String> {
262 if let Some(v) = ctx
263 .request
264 .headers
265 .get(&self.code_header)
266 .and_then(|v| v.first())
267 {
268 if !v.is_empty() {
269 return Some(v.clone());
270 }
271 }
272 ctx.request
273 .query_params
274 .get(&self.code_query)
275 .and_then(|v| v.first())
276 .filter(|v| !v.is_empty())
277 .cloned()
278 }
279
280 async fn fetch_access_token(&self, code: &str) -> Result<(String, Option<u64>), FeishuError> {
283 let body = self.token_request_body(code);
284 let req = OutboundRequest {
285 method: http::Method::POST,
286 url: self.token_url.clone(),
287 headers: vec![("content-type".to_string(), "application/json".to_string())],
288 body: Bytes::from(serde_json::to_vec(&body).unwrap_or_default()),
289 timeout: self.timeout,
290 ssl_verify: self.ssl_verify,
291 tls: None,
292 };
293 let resp = self
294 .resources
295 .outbound
296 .request(req)
297 .await
298 .map_err(|e| FeishuError::Upstream(format!("token callout failed: {}", e)))?;
299 parse_access_token(&resp)
300 }
301
302 fn token_request_body(&self, code: &str) -> serde_json::Value {
304 serde_json::json!({
305 "grant_type": "authorization_code",
306 "client_id": self.app_id,
307 "client_secret": self.app_secret,
308 "redirect_uri": self.auth_redirect_uri,
309 "code": code,
310 })
311 }
312
313 async fn fetch_userinfo(&self, access_token: &str) -> Result<serde_json::Value, FeishuError> {
315 let req = OutboundRequest {
316 method: http::Method::GET,
317 url: self.userinfo_url.clone(),
318 headers: vec![
319 ("content-type".to_string(), "application/json".to_string()),
320 (
321 "authorization".to_string(),
322 format!("Bearer {}", access_token),
323 ),
324 ],
325 body: Bytes::new(),
326 timeout: self.timeout,
327 ssl_verify: self.ssl_verify,
328 tls: None,
329 };
330 let resp = self
331 .resources
332 .outbound
333 .request(req)
334 .await
335 .map_err(|e| FeishuError::Upstream(format!("userinfo callout failed: {}", e)))?;
336 parse_userinfo(&resp)
337 }
338
339 fn reject(ctx: Context, message: &str) -> PluginResult {
343 let mut ctx = ctx;
344 ctx.response.status_code = 401;
345 ctx.response.body = Bytes::from(format!(
346 r#"{{"error": "unauthorized", "message": "{}"}}"#,
347 message.replace('"', "'")
348 ));
349 ctx.response.headers.insert(
350 "content-type".to_string(),
351 vec!["application/json".to_string()],
352 );
353 Ok(PluginOutput::on_port(ctx, "denied"))
354 }
355
356 fn upstream_error(ctx: Context, message: &str) -> PluginResult {
361 let mut ctx = ctx;
362 ctx.response.status_code = 502;
363 Err(PluginExecutionError {
364 context: ctx,
365 error: GatewayError {
366 node_id: String::new(),
367 code: "FEISHU_UPSTREAM_ERROR".to_string(),
368 message: message.to_string(),
369 metadata: HashMap::new(),
370 },
371 })
372 }
373
374 fn store_error(mut ctx: Context, e: StoreError) -> PluginExecutionError {
377 ctx.response.status_code = 503;
378 ctx.response.body = Bytes::from(r#"{"error": "session store unavailable"}"#.as_bytes());
379 ctx.response.headers.insert(
380 "content-type".to_string(),
381 vec!["application/json".to_string()],
382 );
383 PluginExecutionError {
384 context: ctx,
385 error: GatewayError {
386 node_id: String::new(),
387 code: "SESSION_STORE_ERROR".to_string(),
388 message: e.to_string(),
389 metadata: HashMap::new(),
390 },
391 }
392 }
393
394 fn redirect(mut ctx: Context, location: &str, set_cookies: Vec<String>) -> PluginResult {
398 ctx.response.status_code = 302;
399 ctx.response.body = Bytes::new();
400 ctx.response
401 .headers
402 .insert("location".to_string(), vec![location.to_string()]);
403 if !set_cookies.is_empty() {
404 ctx.response
405 .headers
406 .insert("set-cookie".to_string(), set_cookies);
407 }
408 Ok(PluginOutput::on_port(ctx, "redirect"))
409 }
410
411 fn session_attrs<'a>(session: &'a FeishuSession, ctx: &Context) -> CookieAttrs<'a> {
414 CookieAttrs {
415 path: &session.cookie_path,
416 max_age: Some(session.cookie_lifetime),
417 http_only: true,
418 secure: ctx.request.scheme == "https",
419 same_site: SameSite::Lax,
420 }
421 }
422
423 async fn read_session(
435 &self,
436 ctx: &Context,
437 session: &FeishuSession,
438 ) -> Result<(Option<FeishuSessionData>, Option<String>), StoreError> {
439 let Some(cookie_header) = ctx.request.headers.get("cookie").and_then(|v| v.first()) else {
440 return Ok((None, None));
441 };
442 let Some(raw) = read_cookie(cookie_header, &session.cookie_name) else {
443 return Ok((None, None));
444 };
445 let raw = raw.to_string();
446 let Some(bytes) = server_session::load(&session.backend, &session.sealer, &raw).await?
447 else {
448 return Ok((None, None));
449 };
450 match serde_json::from_slice::<FeishuSessionData>(&bytes) {
451 Ok(data) => Ok((Some(data), None)),
452 Err(_) => {
453 let cleared = server_session::destroy(
456 &session.backend,
457 Some(&raw),
458 &session.cookie_name,
459 &session.cookie_path,
460 )
461 .await?;
462 Ok((None, Some(cleared)))
463 }
464 }
465 }
466
467 async fn execute_session(&self, mut ctx: Context, session: &FeishuSession) -> PluginResult {
469 let cleared_cookie = match self.read_session(&ctx, session).await {
470 Ok((Some(data), _)) => {
471 attach_identity(&mut ctx, &data.userinfo, self.set_userinfo_header);
472 return Ok(PluginOutput::success(ctx));
473 }
474 Ok((None, cleared)) => cleared,
475 Err(e) => return Err(Self::store_error(ctx, e)),
476 };
477
478 let code = match self.extract_code(&ctx) {
479 Some(c) => c,
480 None => {
481 let set_cookies = cleared_cookie.into_iter().collect();
482 return Self::redirect(ctx, &session.redirect_uri, set_cookies);
483 }
484 };
485
486 let (access_token, expires_in) = match self.fetch_access_token(&code).await {
487 Ok(t) => t,
488 Err(FeishuError::Unauthorized(m)) => return Self::reject(ctx, &m),
489 Err(e @ FeishuError::Upstream(_)) => return Self::upstream_error(ctx, e.message()),
490 };
491
492 let userinfo = match self.fetch_userinfo(&access_token).await {
493 Ok(u) => u,
494 Err(FeishuError::Unauthorized(m)) => return Self::reject(ctx, &m),
495 Err(e @ FeishuError::Upstream(_)) => return Self::upstream_error(ctx, e.message()),
496 };
497
498 let subject = userinfo
499 .get("user_id")
500 .or_else(|| userinfo.get("open_id"))
501 .or_else(|| userinfo.get("union_id"))
502 .and_then(|v| v.as_str())
503 .unwrap_or("");
504 let ttl = Duration::from_secs(session.cookie_lifetime);
505 let meta = server_session::meta_now(&ctx, "feishu-auth", subject, ttl);
506 let access_token_expires_at = expires_in.map(|secs| now_unix() + secs.saturating_sub(60));
509 let session_data = FeishuSessionData {
510 userinfo: userinfo.clone(),
511 access_token: Some(access_token),
512 access_token_expires_at,
513 };
514 let payload = serde_json::to_vec(&session_data).unwrap_or_default();
515 let attrs = Self::session_attrs(session, &ctx);
516 let set_cookie = match server_session::establish(
517 &session.backend,
518 &session.sealer,
519 &payload,
520 ttl,
521 meta,
522 &session.cookie_name,
523 &attrs,
524 )
525 .await
526 {
527 Ok(s) => s,
528 Err(e) => return Err(Self::store_error(ctx, e)),
529 };
530
531 let target = redirect_target(&ctx, &self.code_query);
538 Self::redirect(ctx, &target, vec![set_cookie])
539 }
540}
541
542fn redirect_target(ctx: &Context, code_query: &str) -> String {
549 let mut uri = ctx.request.path.clone();
550 let mut pairs: Vec<String> = Vec::new();
551 for (k, values) in &ctx.request.query_params {
552 if k == code_query {
553 continue;
554 }
555 for v in values {
556 if v.is_empty() {
557 pairs.push(k.clone());
558 } else {
559 pairs.push(format!("{k}={v}"));
560 }
561 }
562 }
563 if !pairs.is_empty() {
564 uri.push('?');
565 uri.push_str(&pairs.join("&"));
566 }
567 uri
568}
569
570fn require_string(
571 config: &HashMap<String, serde_json::Value>,
572 key: &str,
573) -> Result<String, String> {
574 config
575 .get(key)
576 .and_then(|v| v.as_str())
577 .filter(|s| !s.is_empty())
578 .map(String::from)
579 .ok_or_else(|| format!("feishu-auth plugin requires '{}'", key))
580}
581
582fn session_secret(config: &HashMap<String, serde_json::Value>) -> Option<String> {
584 config
585 .get("session_secret")
586 .and_then(|v| v.as_str())
587 .or_else(|| {
588 config
589 .get("session")
590 .and_then(|s| s.get("secret"))
591 .and_then(|v| v.as_str())
592 })
593 .filter(|s| !s.is_empty())
594 .map(String::from)
595}
596
597fn session_cookie_str(config: &HashMap<String, serde_json::Value>, key: &str) -> Option<String> {
600 config
601 .get("session")
602 .and_then(|s| s.get("cookie"))
603 .and_then(|c| c.get(key))
604 .or_else(|| config.get(&format!("session_cookie_{key}")))
605 .and_then(|v| v.as_str())
606 .filter(|s| !s.is_empty())
607 .map(String::from)
608}
609
610fn session_cookie_u64(config: &HashMap<String, serde_json::Value>, key: &str) -> Option<u64> {
613 config
614 .get("session")
615 .and_then(|s| s.get("cookie"))
616 .and_then(|c| c.get(key))
617 .or_else(|| config.get(&format!("session_cookie_{key}")))
618 .and_then(|v| v.as_u64())
619}
620
621fn now_unix() -> u64 {
622 SystemTime::now()
623 .duration_since(UNIX_EPOCH)
624 .map(|d| d.as_secs())
625 .unwrap_or(0)
626}
627
628fn parse_access_token(resp: &OutboundResponse) -> Result<(String, Option<u64>), FeishuError> {
631 if resp.status != 200 {
632 return Err(FeishuError::Upstream(format!(
633 "unexpected token response status: {}",
634 resp.status
635 )));
636 }
637 let data: serde_json::Value = serde_json::from_slice(&resp.body)
638 .map_err(|e| FeishuError::Upstream(format!("failed to decode token response: {}", e)))?;
639 if let Some(code) = data.get("code").and_then(|v| v.as_i64()) {
642 if code != 0 {
643 let msg = data
644 .get("error_description")
645 .and_then(|v| v.as_str())
646 .or_else(|| data.get("msg").and_then(|v| v.as_str()))
647 .unwrap_or("unknown");
648 return Err(FeishuError::Unauthorized(format!(
649 "feishu rejected code (code {}): {}",
650 code, msg
651 )));
652 }
653 }
654 let token = data
655 .get("access_token")
656 .and_then(|v| v.as_str())
657 .map(String::from)
658 .ok_or_else(|| {
659 FeishuError::Unauthorized("token response missing access_token".to_string())
660 })?;
661 let expires_in = data.get("expires_in").and_then(|v| v.as_u64());
662 Ok((token, expires_in))
663}
664
665fn parse_userinfo(resp: &OutboundResponse) -> Result<serde_json::Value, FeishuError> {
667 if resp.status != 200 {
668 return Err(FeishuError::Upstream(format!(
669 "unexpected userinfo response status: {}",
670 resp.status
671 )));
672 }
673 let data: serde_json::Value = serde_json::from_slice(&resp.body)
674 .map_err(|e| FeishuError::Upstream(format!("failed to decode userinfo response: {}", e)))?;
675 let code = data.get("code").and_then(|v| v.as_i64()).unwrap_or(-1);
676 if code != 0 {
677 let msg = data
678 .get("msg")
679 .and_then(|v| v.as_str())
680 .unwrap_or("unknown");
681 return Err(FeishuError::Unauthorized(format!(
682 "feishu userinfo rejected token (code {}): {}",
683 code, msg
684 )));
685 }
686 data.get("data")
687 .cloned()
688 .ok_or_else(|| FeishuError::Upstream("userinfo response missing data".to_string()))
689}
690
691fn attach_identity(ctx: &mut Context, userinfo: &serde_json::Value, set_header: bool) {
694 ctx.message
695 .insert("feishu_userinfo".to_string(), userinfo.clone());
696 if let Some(uid) = userinfo
697 .get("user_id")
698 .or_else(|| userinfo.get("open_id"))
699 .or_else(|| userinfo.get("union_id"))
700 .and_then(|v| v.as_str())
701 {
702 ctx.message.insert(
703 "user_id".to_string(),
704 serde_json::Value::String(uid.to_string()),
705 );
706 }
707 if set_header {
708 if let Ok(raw) = serde_json::to_vec(userinfo) {
709 ctx.request
710 .headers
711 .insert("x-userinfo".to_string(), vec![BASE64_STANDARD.encode(raw)]);
712 }
713 }
714}
715
716#[async_trait]
717impl Plugin for FeishuAuthPlugin {
718 fn plugin_type(&self) -> &str {
719 "feishu-auth"
720 }
721
722 async fn execute(&self, mut ctx: Context) -> PluginResult {
723 ctx.request.headers.remove("x-userinfo");
725
726 if let Some(session) = &self.session {
727 return self.execute_session(ctx, session).await;
728 }
729
730 let code = match self.extract_code(&ctx) {
731 Some(c) => c,
732 None => return Self::reject(ctx, "Missing Feishu authorization code"),
733 };
734
735 let (access_token, _expires_in) = match self.fetch_access_token(&code).await {
736 Ok(t) => t,
737 Err(FeishuError::Unauthorized(m)) => return Self::reject(ctx, &m),
738 Err(e @ FeishuError::Upstream(_)) => return Self::upstream_error(ctx, e.message()),
739 };
740
741 let userinfo = match self.fetch_userinfo(&access_token).await {
742 Ok(u) => u,
743 Err(FeishuError::Unauthorized(m)) => return Self::reject(ctx, &m),
744 Err(e @ FeishuError::Upstream(_)) => return Self::upstream_error(ctx, e.message()),
745 };
746
747 attach_identity(&mut ctx, &userinfo, self.set_userinfo_header);
748 Ok(PluginOutput::success(ctx))
749 }
750}
751
752#[cfg(test)]
753mod tests {
754 use super::*;
755 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
756
757 fn resp(status: u16, body: serde_json::Value) -> OutboundResponse {
758 OutboundResponse {
759 status,
760 headers: HashMap::new(),
761 body: Bytes::from(serde_json::to_vec(&body).unwrap()),
762 }
763 }
764
765 fn base_ctx() -> Context {
766 Context {
767 request: GatewayRequest {
768 method: "GET".to_string(),
769 path: "/".to_string(),
770 host: "h".to_string(),
771 scheme: "http".to_string(),
772 headers: HashMap::new(),
773 query_params: HashMap::new(),
774 body: Bytes::new(),
775 remote_addr: "1.2.3.4:5".to_string(),
776 protocol: Protocol::Http1,
777 },
778 response: GatewayResponse {
779 status_code: 0,
780 headers: HashMap::new(),
781 body: Bytes::new(),
782 stream: None,
783 },
784 message: HashMap::new(),
785 errors: Vec::new(),
786 }
787 }
788
789 fn full_cfg() -> HashMap<String, serde_json::Value> {
790 [
791 ("app_id", "id"),
792 ("app_secret", "secret"),
793 ("auth_redirect_uri", "https://app/callback"),
794 ]
795 .iter()
796 .map(|(k, v)| (k.to_string(), serde_json::Value::String(v.to_string())))
797 .collect()
798 }
799
800 #[test]
801 fn test_requires_id_secret_redirect() {
802 assert!(FeishuAuthPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err());
803 let mut cfg: HashMap<String, serde_json::Value> = HashMap::new();
805 cfg.insert("app_id".to_string(), serde_json::json!("id"));
806 cfg.insert("app_secret".to_string(), serde_json::json!("secret"));
807 assert!(FeishuAuthPlugin::from_config(&cfg, &PluginResources::empty()).is_err());
808 assert!(FeishuAuthPlugin::from_config(&full_cfg(), &PluginResources::empty()).is_ok());
809 }
810
811 #[test]
812 fn test_token_request_body_shape() {
813 let plugin = FeishuAuthPlugin::from_config(&full_cfg(), &PluginResources::empty()).unwrap();
814 let body = plugin.token_request_body("the-code");
815 assert_eq!(body.get("grant_type").unwrap(), "authorization_code");
816 assert_eq!(body.get("client_id").unwrap(), "id");
817 assert_eq!(body.get("client_secret").unwrap(), "secret");
818 assert_eq!(body.get("redirect_uri").unwrap(), "https://app/callback");
819 assert_eq!(body.get("code").unwrap(), "the-code");
820 }
821
822 #[test]
823 fn test_parse_access_token() {
824 let ok = resp(
825 200,
826 serde_json::json!({ "code": 0, "access_token": "tok", "expires_in": 7200 }),
827 );
828 let (token, expires_in) = parse_access_token(&ok).unwrap();
829 assert_eq!(token, "tok");
830 assert_eq!(expires_in, Some(7200));
831
832 let denied = resp(
834 200,
835 serde_json::json!({ "code": 20037, "error_description": "invalid code" }),
836 );
837 assert!(matches!(
838 parse_access_token(&denied),
839 Err(FeishuError::Unauthorized(_))
840 ));
841
842 let bad_status = resp(400, serde_json::json!({}));
843 assert!(matches!(
844 parse_access_token(&bad_status),
845 Err(FeishuError::Upstream(_))
846 ));
847 }
848
849 #[test]
850 fn test_parse_userinfo() {
851 let ok = resp(
852 200,
853 serde_json::json!({ "code": 0, "data": { "user_id": "u1", "name": "Bob" } }),
854 );
855 let data = parse_userinfo(&ok).unwrap();
856 assert_eq!(data.get("user_id").unwrap(), "u1");
857
858 let denied = resp(
859 200,
860 serde_json::json!({ "code": 99991663, "msg": "token invalid" }),
861 );
862 assert!(matches!(
863 parse_userinfo(&denied),
864 Err(FeishuError::Unauthorized(_))
865 ));
866 }
867
868 #[test]
869 fn test_attach_identity() {
870 let mut ctx = base_ctx();
871 let userinfo = serde_json::json!({ "user_id": "u1", "open_id": "ou_x", "name": "Bob" });
872 attach_identity(&mut ctx, &userinfo, true);
873 assert_eq!(ctx.message.get("user_id").unwrap(), "u1");
874 assert!(ctx.request.headers.contains_key("x-userinfo"));
875 }
876
877 #[tokio::test]
878 async fn test_missing_code_rejected_401() {
879 let plugin = FeishuAuthPlugin::from_config(&full_cfg(), &PluginResources::empty()).unwrap();
880 let out = plugin.execute(base_ctx()).await.unwrap();
881 assert_eq!(out.port, Some("denied"));
882 assert_eq!(out.context.response.status_code, 401);
883 }
884
885 #[tokio::test]
886 async fn test_upstream_callout_failure_stays_on_error_port() {
887 let mut cfg = full_cfg();
891 cfg.insert(
892 "access_token_url".to_string(),
893 serde_json::json!("http://127.0.0.1:1"),
894 );
895 cfg.insert("timeout".to_string(), serde_json::json!(200));
896 let plugin = FeishuAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
897
898 let mut ctx = base_ctx();
899 ctx.request
900 .query_params
901 .insert("code".to_string(), vec!["some-code".to_string()]);
902 let err = plugin.execute(ctx).await.unwrap_err();
903 assert_eq!(err.error.code, "FEISHU_UPSTREAM_ERROR");
904 assert!(err.context.response.status_code >= 500);
905 }
906
907 #[test]
908 fn test_stateless_mode_unchanged() {
909 let plugin = FeishuAuthPlugin::from_config(&full_cfg(), &PluginResources::empty()).unwrap();
912 assert!(plugin.session.is_none());
913 }
914
915 #[test]
916 fn test_session_mode_requires_redirect_uri() {
917 let mut cfg = full_cfg();
919 cfg.insert(
920 "session".to_string(),
921 serde_json::json!({ "secret": "s3cr3t" }),
922 );
923 let err = FeishuAuthPlugin::from_config(&cfg, &PluginResources::empty())
924 .err()
925 .unwrap();
926 assert!(err.contains("redirect_uri"), "{err}");
927 }
928
929 #[tokio::test]
930 async fn test_session_mode_no_code_redirects() {
931 let mut cfg = full_cfg();
932 cfg.insert(
933 "redirect_uri".to_string(),
934 serde_json::json!("https://login.example.com/start"),
935 );
936 cfg.insert(
937 "session".to_string(),
938 serde_json::json!({"secret": "s3cr3t"}),
939 );
940 let plugin = FeishuAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
941 let out = plugin.execute(base_ctx()).await.unwrap();
942 assert_eq!(out.port, Some("redirect"));
943 assert_eq!(out.context.response.status_code, 302);
944 assert_eq!(
945 out.context.response.headers["location"],
946 vec!["https://login.example.com/start".to_string()]
947 );
948 }
949
950 #[tokio::test]
951 async fn test_session_mode_valid_cookie_skips_callout() {
952 let mut config = full_cfg();
956 config.insert(
957 "access_token_url".to_string(),
958 serde_json::json!("http://127.0.0.1:1"),
959 );
960 config.insert(
961 "userinfo_url".to_string(),
962 serde_json::json!("http://127.0.0.1:1"),
963 );
964 config.insert(
965 "redirect_uri".to_string(),
966 serde_json::json!("https://login.example.com/start"),
967 );
968 config.insert("timeout".to_string(), serde_json::json!(200));
969 config.insert(
970 "session".to_string(),
971 serde_json::json!({ "secret": "s3cr3t" }),
972 );
973 let plugin = FeishuAuthPlugin::from_config(&config, &PluginResources::empty()).unwrap();
974
975 let sealer = CookieSealer::new("s3cr3t");
976 let session_data = FeishuSessionData {
977 userinfo: serde_json::json!({ "user_id": "u1" }),
978 access_token: Some("cached-token".to_string()),
979 access_token_expires_at: Some(1_000_000),
980 };
981 let payload = serde_json::to_vec(&session_data).unwrap();
982 let sealed = sealer.seal(&payload, Duration::from_secs(86_400));
983
984 let mut ctx = base_ctx();
985 ctx.request.headers.insert(
986 "cookie".to_string(),
987 vec![format!("feishu_session={}", sealed)],
988 );
989
990 let out = plugin.execute(ctx).await.unwrap();
991 assert!(out.port.is_none());
992 assert_eq!(out.context.message.get("user_id").unwrap(), "u1");
993 }
994
995 async fn spawn_json_server(body: serde_json::Value) -> u16 {
998 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
999 let port = listener.local_addr().unwrap().port();
1000 tokio::spawn(async move {
1001 if let Ok((mut stream, _)) = listener.accept().await {
1002 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1003 let mut buf = [0u8; 4096];
1004 let _ = stream.read(&mut buf).await;
1005 let body = body.to_string();
1006 let _ = stream
1007 .write_all(
1008 format!(
1009 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
1010 body.len(),
1011 body
1012 )
1013 .as_bytes(),
1014 )
1015 .await;
1016 let _ = stream.shutdown().await;
1017 }
1018 });
1019 port
1020 }
1021
1022 #[tokio::test]
1030 async fn test_session_mode_code_exchange_redirects_with_cookie() {
1031 let token_port = spawn_json_server(serde_json::json!({
1032 "code": 0,
1033 "access_token": "user-token",
1034 "expires_in": 7200
1035 }))
1036 .await;
1037 let userinfo_port = spawn_json_server(serde_json::json!({
1038 "code": 0,
1039 "data": { "user_id": "u1", "name": "Bob" }
1040 }))
1041 .await;
1042
1043 let mut config = full_cfg();
1044 let token_url = format!("http://127.0.0.1:{}", token_port);
1045 let userinfo_url = format!("http://127.0.0.1:{}", userinfo_port);
1046 config.insert("access_token_url".to_string(), serde_json::json!(token_url));
1047 config.insert("userinfo_url".to_string(), serde_json::json!(userinfo_url));
1048 config.insert(
1049 "redirect_uri".to_string(),
1050 serde_json::json!("https://login.example.com/start"),
1051 );
1052 config.insert("timeout".to_string(), serde_json::json!(2000));
1053 config.insert(
1054 "session".to_string(),
1055 serde_json::json!({ "secret": "s3cr3t" }),
1056 );
1057 let plugin = FeishuAuthPlugin::from_config(&config, &PluginResources::empty()).unwrap();
1058
1059 let mut ctx = base_ctx();
1060 ctx.request.path = "/callback".to_string();
1061 ctx.request
1062 .query_params
1063 .insert("code".to_string(), vec!["one-time-code".to_string()]);
1064 ctx.request
1065 .query_params
1066 .insert("foo".to_string(), vec!["bar".to_string()]);
1067
1068 let out = plugin.execute(ctx).await.unwrap();
1069 assert_eq!(out.port, Some("redirect"));
1070 assert_eq!(out.context.response.status_code, 302);
1071 assert_eq!(
1072 out.context.response.headers["location"],
1073 vec!["/callback?foo=bar".to_string()]
1074 );
1075 assert!(!out.context.message.contains_key("user_id"));
1078 let set_cookie = out.context.response.headers["set-cookie"][0].clone();
1079 assert!(set_cookie.starts_with("feishu_session="), "{set_cookie}");
1080
1081 let cookie_value = set_cookie.split(';').next().unwrap().to_string();
1084 let mut ctx2 = base_ctx();
1085 ctx2.request
1086 .headers
1087 .insert("cookie".to_string(), vec![cookie_value]);
1088 let out2 = plugin.execute(ctx2).await.unwrap();
1089 assert!(out2.port.is_none());
1090 assert_eq!(out2.context.message.get("user_id").unwrap(), "u1");
1091 }
1092
1093 #[cfg(feature = "redis-store")]
1094 fn resources_with_fake_store() -> (Arc<PluginResources>, Arc<crate::sessions::FakeSessionStore>)
1095 {
1096 let fake = Arc::new(crate::sessions::FakeSessionStore::default());
1097 let resources = PluginResources::empty();
1098 resources.stores.store(Arc::new(
1099 crate::stores::StoreRegistry::with_fake_session_store("s1", fake.clone()),
1100 ));
1101 (resources, fake)
1102 }
1103
1104 #[cfg(feature = "redis-store")]
1108 #[tokio::test]
1109 async fn test_redis_session_read_and_store_outage_503() {
1110 use crate::sessions::SessionStore as _;
1111
1112 let (resources, fake) = resources_with_fake_store();
1113 let mut config = full_cfg();
1114 config.insert(
1115 "access_token_url".to_string(),
1116 serde_json::json!("http://127.0.0.1:1"),
1117 );
1118 config.insert(
1119 "userinfo_url".to_string(),
1120 serde_json::json!("http://127.0.0.1:1"),
1121 );
1122 config.insert(
1123 "redirect_uri".to_string(),
1124 serde_json::json!("https://login.example.com/start"),
1125 );
1126 config.insert("timeout".to_string(), serde_json::json!(200));
1127 config.insert(
1128 "session".to_string(),
1129 serde_json::json!({ "secret": "s3cr3t", "storage": "redis", "store": "s1" }),
1130 );
1131 let plugin = FeishuAuthPlugin::from_config(&config, &resources).unwrap();
1132
1133 let sealer = CookieSealer::new("s3cr3t");
1136 let session_data = FeishuSessionData {
1137 userinfo: serde_json::json!({ "user_id": "u1" }),
1138 access_token: None,
1139 access_token_expires_at: None,
1140 };
1141 let payload = serde_json::to_vec(&session_data).unwrap();
1142 let sealed = sealer.seal(&payload, Duration::from_secs(86_400));
1143 let id = crate::sessions::SessionId::random();
1144 let meta = crate::sessions::SessionMeta {
1145 id: String::new(),
1146 subject: "u1".to_string(),
1147 plugin: "feishu-auth".to_string(),
1148 policy: String::new(),
1149 route: String::new(),
1150 created_at: 0,
1151 expires_at: 0,
1152 };
1153 fake.put(&id, sealed.as_bytes(), Duration::from_secs(86_400), &meta)
1154 .await
1155 .unwrap();
1156
1157 let mut ctx = base_ctx();
1158 ctx.request.headers.insert(
1159 "cookie".to_string(),
1160 vec![format!("feishu_session={}", id.as_str())],
1161 );
1162 let out = plugin.execute(ctx).await.unwrap();
1163 assert!(out.port.is_none());
1164 assert_eq!(out.context.message.get("user_id").unwrap(), "u1");
1165
1166 fake.fail.store(true, std::sync::atomic::Ordering::Relaxed);
1168 let mut ctx = base_ctx();
1169 ctx.request.headers.insert(
1170 "cookie".to_string(),
1171 vec![format!("feishu_session={}", id.as_str())],
1172 );
1173 let err = plugin.execute(ctx).await.unwrap_err();
1174 assert_eq!(err.error.code, "SESSION_STORE_ERROR");
1175 assert_eq!(err.context.response.status_code, 503);
1176 }
1177}