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