Skip to main content

featherbit/plugins/native/
dingtalk_auth.rs

1//! DingTalk authentication plugin (`dingtalk-auth`).
2//!
3//! Validates a DingTalk authorization *code* by exchanging it, through
4//! DingTalk's OAuth API, for the calling user's identity, then attaches that
5//! identity to the request for downstream nodes. A request whose code cannot
6//! be resolved to a DingTalk user is rejected with a `401`.
7//!
8//! # Ported subset / deviations from APISIX
9//!
10//! APISIX's `dingtalk-auth` is a *session* plugin: on the first request it
11//! reads a code, calls DingTalk, then stores the resolved userinfo in an
12//! encrypted `dingtalk_session` cookie so later requests skip the callout, and
13//! it 302-redirects to `redirect_uri` when no code and no session are present.
14//! featherbit is stateless with no session store, so this port implements the
15//! **token-validation subset**: every request must carry a code, which is
16//! validated against DingTalk on each request. Consequently the session /
17//! cookie / redirect machinery is dropped, along with the config keys that only
18//! served it (`secret`, `secret_fallbacks`, `redirect_uri`, `cookie_expires_in`).
19//! The app-level access token *is* cached in-process (7000s TTL, matching
20//! APISIX's `lrucache`) so only the userinfo call happens per request.
21
22use 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";
38/// DingTalk access tokens live 7200s; cache slightly shorter to avoid using a
39/// token that expires mid-flight (matches APISIX's cache TTL).
40const ACCESS_TOKEN_TTL: Duration = Duration::from_secs(7000);
41
42/// Outcome of resolving a DingTalk code into userinfo. Every failure maps to a
43/// `401` (`DINGTALK_AUTH_FAILED`); the variants exist to keep the reason legible.
44#[derive(Debug)]
45enum DingtalkError {
46    /// DingTalk rejected the code / access token (auth failure).
47    Unauthorized(String),
48    /// The callout itself failed (network, non-200, unparseable body).
49    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
60/// Authenticates requests by resolving a DingTalk authorization code to a
61/// DingTalk user via the OAuth `accessToken` + `getuserinfo` APIs.
62pub struct DingtalkAuthPlugin {
63    app_key: String,
64    app_secret: String,
65    /// Lowercased header the code is read from first.
66    code_header: String,
67    /// Query parameter the code falls back to.
68    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    /// In-process cache of the app-level access token: `(token, fetched_at)`.
76    token_cache: Mutex<Option<(String, Instant)>>,
77}
78
79impl DingtalkAuthPlugin {
80    /// Builds the plugin from node config.
81    ///
82    /// Accepted keys:
83    /// - `app_key` (string, required): DingTalk application key.
84    /// - `app_secret` (string, required): DingTalk application secret.
85    /// - `code_header` (string, default `"X-DingTalk-Code"`): header the
86    ///   authorization code is read from first (matched case-insensitively).
87    /// - `code_query` (string, default `"code"`): query parameter the code
88    ///   falls back to when the header is absent.
89    /// - `access_token_url` (string, default DingTalk's `oauth2/accessToken`).
90    /// - `userinfo_url` (string, default DingTalk's `v2/user/getuserinfo`).
91    /// - `set_userinfo_header` (bool, default `true`): when true the resolved
92    ///   userinfo JSON is base64-encoded into the `X-Userinfo` request header
93    ///   for the upstream.
94    /// - `timeout` (integer ms, default `6000`): per-callout timeout.
95    /// - `ssl_verify` (bool, default `true`): verify DingTalk's TLS certificate.
96    ///
97    /// Session-only APISIX keys (`secret`, `secret_fallbacks`, `redirect_uri`,
98    /// `cookie_expires_in`) are not accepted — see the module docs.
99    ///
100    /// ```yaml
101    /// type: dingtalk-auth
102    /// config:
103    ///   app_key: ${DINGTALK_APP_KEY}
104    ///   app_secret: ${DINGTALK_APP_SECRET}
105    ///   code_header: X-DingTalk-Code
106    /// ```
107    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    /// Reads the authorization code from the configured header, falling back to
165    /// the query parameter.
166    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    /// Returns a valid access token, using the in-process cache when fresh and
186    /// fetching a new one from DingTalk otherwise.
187    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    /// Exchanges the code for DingTalk userinfo using `access_token`.
222    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    /// Builds the `401` rejection carrying the context so the graph engine
248    /// routes through the error port.
249    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
272/// Extracts a required string config key.
273fn 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
285/// Appends `key=value` to `url`, choosing `?` or `&` as needed.
286fn 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
291/// Minimal percent-encoding for query values (access tokens are URL-safe-ish
292/// but may contain `+` / `=`).
293fn 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
306/// Parses the `accessToken` from DingTalk's token-endpoint response.
307fn 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
322/// Parses DingTalk's `getuserinfo` response, returning the `result` object on
323/// `errcode == 0` and an [`DingtalkError::Unauthorized`] otherwise.
324fn 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
350/// Copies the resolved identity into `context.message` and optionally the
351/// `X-Userinfo` request header.
352fn 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        // Never let a client-supplied X-Userinfo bleed through to the upstream.
386        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        // header wins over query
479        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        // errcode != 0 → unauthorized (invalid code)
517        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}