1use async_trait::async_trait;
23use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
24use base64::Engine;
25use bytes::Bytes;
26use std::collections::HashMap;
27use std::sync::Arc;
28use std::time::{Duration, Instant};
29use tokio::sync::Mutex;
30
31use crate::context::{Context, GatewayError};
32use crate::outbound::{OutboundRequest, OutboundResponse};
33use crate::plugins::resources::PluginResources;
34use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
35
36const DEFAULT_USERINFO_URL: &str = "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo";
37const DEFAULT_TOKEN_URL: &str = "https://api.dingtalk.com/v1.0/oauth2/accessToken";
38const ACCESS_TOKEN_TTL: Duration = Duration::from_secs(7000);
41
42#[derive(Debug)]
45enum DingtalkError {
46 Unauthorized(String),
48 Upstream(String),
50}
51
52impl DingtalkError {
53 fn message(&self) -> &str {
54 match self {
55 DingtalkError::Unauthorized(m) | DingtalkError::Upstream(m) => m,
56 }
57 }
58}
59
60pub struct DingtalkAuthPlugin {
63 app_key: String,
64 app_secret: String,
65 code_header: String,
67 code_query: String,
69 token_url: String,
70 userinfo_url: String,
71 set_userinfo_header: bool,
72 timeout: Duration,
73 ssl_verify: bool,
74 resources: Arc<PluginResources>,
75 token_cache: Mutex<Option<(String, Instant)>>,
77}
78
79impl DingtalkAuthPlugin {
80 pub fn from_config(
108 config: &HashMap<String, serde_json::Value>,
109 resources: &Arc<PluginResources>,
110 ) -> Result<Self, String> {
111 let app_key = require_string(config, "app_key")?;
112 let app_secret = require_string(config, "app_secret")?;
113
114 let code_header = config
115 .get("code_header")
116 .and_then(|v| v.as_str())
117 .unwrap_or("X-DingTalk-Code")
118 .to_lowercase();
119 let code_query = config
120 .get("code_query")
121 .and_then(|v| v.as_str())
122 .unwrap_or("code")
123 .to_string();
124 let token_url = config
125 .get("access_token_url")
126 .and_then(|v| v.as_str())
127 .unwrap_or(DEFAULT_TOKEN_URL)
128 .to_string();
129 let userinfo_url = config
130 .get("userinfo_url")
131 .and_then(|v| v.as_str())
132 .unwrap_or(DEFAULT_USERINFO_URL)
133 .to_string();
134 let set_userinfo_header = config
135 .get("set_userinfo_header")
136 .and_then(|v| v.as_bool())
137 .unwrap_or(true);
138 let timeout = Duration::from_millis(
139 config
140 .get("timeout")
141 .and_then(|v| v.as_u64())
142 .unwrap_or(6000),
143 );
144 let ssl_verify = config
145 .get("ssl_verify")
146 .and_then(|v| v.as_bool())
147 .unwrap_or(true);
148
149 Ok(Self {
150 app_key,
151 app_secret,
152 code_header,
153 code_query,
154 token_url,
155 userinfo_url,
156 set_userinfo_header,
157 timeout,
158 ssl_verify,
159 resources: resources.clone(),
160 token_cache: Mutex::new(None),
161 })
162 }
163
164 fn extract_code(&self, ctx: &Context) -> Option<String> {
167 if let Some(v) = ctx
168 .request
169 .headers
170 .get(&self.code_header)
171 .and_then(|v| v.first())
172 {
173 if !v.is_empty() {
174 return Some(v.clone());
175 }
176 }
177 ctx.request
178 .query_params
179 .get(&self.code_query)
180 .and_then(|v| v.first())
181 .filter(|v| !v.is_empty())
182 .cloned()
183 }
184
185 async fn access_token(&self) -> Result<String, DingtalkError> {
188 {
189 let cache = self.token_cache.lock().await;
190 if let Some((token, fetched_at)) = cache.as_ref() {
191 if fetched_at.elapsed() < ACCESS_TOKEN_TTL {
192 return Ok(token.clone());
193 }
194 }
195 }
196
197 let body = serde_json::json!({
198 "appKey": self.app_key,
199 "appSecret": self.app_secret,
200 });
201 let req = OutboundRequest {
202 method: http::Method::POST,
203 url: self.token_url.clone(),
204 headers: vec![("content-type".to_string(), "application/json".to_string())],
205 body: Bytes::from(serde_json::to_vec(&body).unwrap_or_default()),
206 timeout: self.timeout,
207 ssl_verify: self.ssl_verify,
208 tls: None,
209 };
210 let resp =
211 self.resources.outbound.request(req).await.map_err(|e| {
212 DingtalkError::Upstream(format!("access token callout failed: {}", e))
213 })?;
214 let token = parse_access_token(&resp)?;
215
216 let mut cache = self.token_cache.lock().await;
217 *cache = Some((token.clone(), Instant::now()));
218 Ok(token)
219 }
220
221 async fn fetch_userinfo(
223 &self,
224 access_token: &str,
225 code: &str,
226 ) -> Result<serde_json::Value, DingtalkError> {
227 let url = append_query(&self.userinfo_url, "access_token", access_token);
228 let body = serde_json::json!({ "code": code });
229 let req = OutboundRequest {
230 method: http::Method::POST,
231 url,
232 headers: vec![("content-type".to_string(), "application/json".to_string())],
233 body: Bytes::from(serde_json::to_vec(&body).unwrap_or_default()),
234 timeout: self.timeout,
235 ssl_verify: self.ssl_verify,
236 tls: None,
237 };
238 let resp = self
239 .resources
240 .outbound
241 .request(req)
242 .await
243 .map_err(|e| DingtalkError::Upstream(format!("userinfo callout failed: {}", e)))?;
244 parse_userinfo(&resp)
245 }
246
247 fn reject(ctx: Context, message: &str) -> PluginResult {
250 let mut ctx = ctx;
251 ctx.response.status_code = 401;
252 ctx.response.body = Bytes::from(format!(
253 r#"{{"error": "unauthorized", "message": "{}"}}"#,
254 message.replace('"', "'")
255 ));
256 ctx.response.headers.insert(
257 "content-type".to_string(),
258 vec!["application/json".to_string()],
259 );
260 Err(PluginExecutionError {
261 context: ctx,
262 error: GatewayError {
263 node_id: String::new(),
264 code: "DINGTALK_AUTH_FAILED".to_string(),
265 message: message.to_string(),
266 metadata: HashMap::new(),
267 },
268 })
269 }
270}
271
272fn require_string(
274 config: &HashMap<String, serde_json::Value>,
275 key: &str,
276) -> Result<String, String> {
277 config
278 .get(key)
279 .and_then(|v| v.as_str())
280 .filter(|s| !s.is_empty())
281 .map(String::from)
282 .ok_or_else(|| format!("dingtalk-auth plugin requires '{}'", key))
283}
284
285fn append_query(url: &str, key: &str, value: &str) -> String {
287 let sep = if url.contains('?') { '&' } else { '?' };
288 format!("{}{}{}={}", url, sep, key, urlencode(value))
289}
290
291fn urlencode(value: &str) -> String {
294 let mut out = String::with_capacity(value.len());
295 for b in value.bytes() {
296 match b {
297 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
298 out.push(b as char)
299 }
300 _ => out.push_str(&format!("%{:02X}", b)),
301 }
302 }
303 out
304}
305
306fn parse_access_token(resp: &OutboundResponse) -> Result<String, DingtalkError> {
308 if resp.status != 200 {
309 return Err(DingtalkError::Upstream(format!(
310 "unexpected token response status: {}",
311 resp.status
312 )));
313 }
314 let data: serde_json::Value = serde_json::from_slice(&resp.body)
315 .map_err(|e| DingtalkError::Upstream(format!("failed to decode token response: {}", e)))?;
316 data.get("accessToken")
317 .and_then(|v| v.as_str())
318 .map(String::from)
319 .ok_or_else(|| DingtalkError::Upstream("token response missing accessToken".to_string()))
320}
321
322fn parse_userinfo(resp: &OutboundResponse) -> Result<serde_json::Value, DingtalkError> {
325 if resp.status != 200 {
326 return Err(DingtalkError::Upstream(format!(
327 "unexpected userinfo response status: {}",
328 resp.status
329 )));
330 }
331 let data: serde_json::Value = serde_json::from_slice(&resp.body).map_err(|e| {
332 DingtalkError::Upstream(format!("failed to decode userinfo response: {}", e))
333 })?;
334 let errcode = data.get("errcode").and_then(|v| v.as_i64()).unwrap_or(-1);
335 if errcode != 0 {
336 let errmsg = data
337 .get("errmsg")
338 .and_then(|v| v.as_str())
339 .unwrap_or("unknown");
340 return Err(DingtalkError::Unauthorized(format!(
341 "dingtalk rejected code (errcode {}): {}",
342 errcode, errmsg
343 )));
344 }
345 data.get("result")
346 .cloned()
347 .ok_or_else(|| DingtalkError::Upstream("userinfo response missing result".to_string()))
348}
349
350fn attach_identity(ctx: &mut Context, userinfo: &serde_json::Value, set_header: bool) {
353 ctx.message
354 .insert("dingtalk_userinfo".to_string(), userinfo.clone());
355 if let Some(uid) = userinfo
356 .get("userid")
357 .or_else(|| userinfo.get("unionid"))
358 .and_then(|v| v.as_str())
359 {
360 ctx.message.insert(
361 "user_id".to_string(),
362 serde_json::Value::String(uid.to_string()),
363 );
364 }
365 if set_header {
366 if let Ok(raw) = serde_json::to_vec(userinfo) {
367 ctx.request
368 .headers
369 .insert("x-userinfo".to_string(), vec![BASE64_STANDARD.encode(raw)]);
370 }
371 }
372}
373
374#[async_trait]
375impl Plugin for DingtalkAuthPlugin {
376 fn plugin_type(&self) -> &str {
377 "dingtalk-auth"
378 }
379
380 async fn execute(
381 &self,
382 mut ctx: Context,
383 _named_inputs: &HashMap<String, serde_json::Value>,
384 ) -> PluginResult {
385 ctx.request.headers.remove("x-userinfo");
387
388 let code = match self.extract_code(&ctx) {
389 Some(c) => c,
390 None => return Self::reject(ctx, "Missing DingTalk authorization code"),
391 };
392
393 let access_token = match self.access_token().await {
394 Ok(t) => t,
395 Err(e) => return Self::reject(ctx, e.message()),
396 };
397
398 let userinfo = match self.fetch_userinfo(&access_token, &code).await {
399 Ok(u) => u,
400 Err(e) => return Self::reject(ctx, e.message()),
401 };
402
403 attach_identity(&mut ctx, &userinfo, self.set_userinfo_header);
404 Ok(PluginOutput {
405 context: ctx,
406 named_outputs: HashMap::new(),
407 })
408 }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
415
416 fn resp(status: u16, body: serde_json::Value) -> OutboundResponse {
417 OutboundResponse {
418 status,
419 headers: HashMap::new(),
420 body: Bytes::from(serde_json::to_vec(&body).unwrap()),
421 }
422 }
423
424 fn base_ctx() -> Context {
425 Context {
426 request: GatewayRequest {
427 method: "GET".to_string(),
428 path: "/".to_string(),
429 host: "h".to_string(),
430 scheme: "http".to_string(),
431 headers: HashMap::new(),
432 query_params: HashMap::new(),
433 body: Bytes::new(),
434 remote_addr: "1.2.3.4:5".to_string(),
435 protocol: Protocol::Http1,
436 },
437 response: GatewayResponse {
438 status_code: 0,
439 headers: HashMap::new(),
440 body: Bytes::new(),
441 },
442 message: HashMap::new(),
443 errors: Vec::new(),
444 }
445 }
446
447 fn cfg(pairs: &[(&str, &str)]) -> HashMap<String, serde_json::Value> {
448 pairs
449 .iter()
450 .map(|(k, v)| (k.to_string(), serde_json::Value::String(v.to_string())))
451 .collect()
452 }
453
454 #[test]
455 fn test_requires_app_key_and_secret() {
456 assert!(
457 DingtalkAuthPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err()
458 );
459 let only_key = cfg(&[("app_key", "k")]);
460 assert!(DingtalkAuthPlugin::from_config(&only_key, &PluginResources::empty()).is_err());
461 let both = cfg(&[("app_key", "k"), ("app_secret", "s")]);
462 assert!(DingtalkAuthPlugin::from_config(&both, &PluginResources::empty()).is_ok());
463 }
464
465 #[test]
466 fn test_extract_code_header_then_query() {
467 let cfg = cfg(&[("app_key", "k"), ("app_secret", "s")]);
468 let plugin = DingtalkAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
469
470 let mut ctx = base_ctx();
471 assert_eq!(plugin.extract_code(&ctx), None);
472
473 ctx.request
474 .query_params
475 .insert("code".to_string(), vec!["from-query".to_string()]);
476 assert_eq!(plugin.extract_code(&ctx), Some("from-query".to_string()));
477
478 ctx.request.headers.insert(
480 "x-dingtalk-code".to_string(),
481 vec!["from-header".to_string()],
482 );
483 assert_eq!(plugin.extract_code(&ctx), Some("from-header".to_string()));
484 }
485
486 #[test]
487 fn test_parse_access_token() {
488 let ok = resp(
489 200,
490 serde_json::json!({ "accessToken": "abc", "expireIn": 7200 }),
491 );
492 assert_eq!(parse_access_token(&ok).unwrap(), "abc");
493
494 let missing = resp(200, serde_json::json!({ "expireIn": 7200 }));
495 assert!(matches!(
496 parse_access_token(&missing),
497 Err(DingtalkError::Upstream(_))
498 ));
499
500 let bad_status = resp(500, serde_json::json!({}));
501 assert!(matches!(
502 parse_access_token(&bad_status),
503 Err(DingtalkError::Upstream(_))
504 ));
505 }
506
507 #[test]
508 fn test_parse_userinfo_success_and_auth_error() {
509 let ok = resp(
510 200,
511 serde_json::json!({ "errcode": 0, "result": { "userid": "u1", "name": "Alice" } }),
512 );
513 let result = parse_userinfo(&ok).unwrap();
514 assert_eq!(result.get("userid").unwrap(), "u1");
515
516 let denied = resp(
518 200,
519 serde_json::json!({ "errcode": 40078, "errmsg": "invalid code" }),
520 );
521 assert!(matches!(
522 parse_userinfo(&denied),
523 Err(DingtalkError::Unauthorized(_))
524 ));
525 }
526
527 #[test]
528 fn test_attach_identity_sets_message_and_header() {
529 let mut ctx = base_ctx();
530 let userinfo = serde_json::json!({ "userid": "u1", "name": "Alice" });
531 attach_identity(&mut ctx, &userinfo, true);
532 assert_eq!(ctx.message.get("user_id").unwrap(), "u1");
533 assert!(ctx.message.contains_key("dingtalk_userinfo"));
534 let header = ctx
535 .request
536 .headers
537 .get("x-userinfo")
538 .unwrap()
539 .first()
540 .unwrap();
541 let decoded = BASE64_STANDARD.decode(header).unwrap();
542 let round: serde_json::Value = serde_json::from_slice(&decoded).unwrap();
543 assert_eq!(round.get("name").unwrap(), "Alice");
544 }
545
546 #[test]
547 fn test_append_query() {
548 assert_eq!(
549 append_query("http://x/y", "access_token", "a b"),
550 "http://x/y?access_token=a%20b"
551 );
552 assert_eq!(
553 append_query("http://x/y?z=1", "access_token", "tok"),
554 "http://x/y?z=1&access_token=tok"
555 );
556 }
557
558 #[tokio::test]
559 async fn test_missing_code_rejected_401() {
560 let cfg = cfg(&[("app_key", "k"), ("app_secret", "s")]);
561 let plugin = DingtalkAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
562 let out = plugin.execute(base_ctx(), &HashMap::new()).await;
563 let err = out.unwrap_err();
564 assert_eq!(err.context.response.status_code, 401);
565 assert_eq!(err.error.code, "DINGTALK_AUTH_FAILED");
566 }
567}