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 is
6//! missing or that DingTalk actively rejects is denied with a `401` on the
7//! `denied` port; a DingTalk callout that fails outright (network error,
8//! non-200, unparseable body) is a genuine infrastructure failure and stays
9//! on the `error` port.
10//!
11//! # Ported subset / deviations from APISIX
12//!
13//! APISIX's `dingtalk-auth` is a *session* plugin: on the first request it
14//! reads a code, calls DingTalk, then stores the resolved userinfo in an
15//! encrypted `dingtalk_session` cookie so later requests skip the callout, and
16//! it 302-redirects to `redirect_uri` when no code and no session are present.
17//! That session machinery is now restored on an opt-in basis, sharing the
18//! same [cookie](crate::plugins::util::cookie_session) /
19//! [server-store](crate::plugins::util::server_session) primitives as
20//! `cas-auth`/`openid-connect`/`authz-casdoor`:
21//!
22//! - **Stateless (default)** — when no `session.secret` is configured the
23//!   node behaves exactly as before: every request must carry a code, which
24//!   is validated against DingTalk on each request. No cookie is read or
25//!   set.
26//! - **Session (opt-in)** — set `session.secret` (or `session_secret`) to
27//!   turn the flow back on: strip any client-supplied `x-userinfo`, then read
28//!   the `dingtalk_session` cookie (cookie mode: the userinfo JSON is sealed
29//!   directly in the cookie; redis mode via `session.storage: redis` +
30//!   `session.store: <name>`: the cookie carries a bare id, the sealed
31//!   userinfo lives server-side). A valid session attaches identity straight
32//!   from the stored payload — no DingTalk callout. An undecodable payload
33//!   (corrupt/stale format) is destroyed and treated as no session rather
34//!   than failing the request. No session and no code 302-redirects to the
35//!   required `redirect_uri` (the `redirect` port). A code present runs the
36//!   existing token+userinfo callouts unchanged, then establishes a new
37//!   session (payload = the userinfo `result` JSON; subject = `userid` else
38//!   `unionid` else empty; ttl = `session.cookie.lifetime`, APISIX's
39//!   `cookie_expires_in`, default `86400`) and 302-redirects (the `redirect`
40//!   port) to the current URL with the `code` query parameter stripped,
41//!   carrying the session `Set-Cookie` — the graph wires `success` straight
42//!   to `upstream.in`, which replaces `ctx.response.headers` wholesale, so a
43//!   Set-Cookie attached on that path would never reach the browser. The
44//!   browser's follow-up request then hits the session-read fast path above
45//!   and attaches identity. A session-store outage is a `503`
46//!   (`SESSION_STORE_ERROR`) on the `error` port, never a silent
47//!   re-login. The app-level access token is still cached in-process (7000s
48//!   TTL, matching APISIX's `lrucache`).
49//!
50//! Remaining deviation: `secret_fallbacks` (APISIX's multi-secret key
51//! rotation) is **not** supported — the sealer is a single `session.secret`,
52//! same as every other session plugin in this codebase.
53
54use 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";
73/// DingTalk access tokens live 7200s; cache slightly shorter to avoid using a
74/// token that expires mid-flight (matches APISIX's cache TTL).
75const ACCESS_TOKEN_TTL: Duration = Duration::from_secs(7000);
76
77/// Outcome of resolving a DingTalk code into userinfo. `Unauthorized` is a
78/// deliberate denial (exits `denied`, `401`); `Upstream` is a genuine callout
79/// failure (exits `error`).
80#[derive(Debug)]
81enum DingtalkError {
82    /// DingTalk rejected the code / access token (auth failure).
83    Unauthorized(String),
84    /// The callout itself failed (network, non-200, unparseable body).
85    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
96/// Session-mode settings (present when `session.secret` is configured).
97struct 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
106/// Authenticates requests by resolving a DingTalk authorization code to a
107/// DingTalk user via the OAuth `accessToken` + `getuserinfo` APIs.
108pub struct DingtalkAuthPlugin {
109    app_key: String,
110    app_secret: String,
111    /// Lowercased header the code is read from first.
112    code_header: String,
113    /// Query parameter the code falls back to.
114    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    /// In-process cache of the app-level access token: `(token, fetched_at)`.
122    token_cache: Mutex<Option<(String, Instant)>>,
123    /// Session-mode settings; `None` keeps the stateless token-validation
124    /// behavior (the pre-existing, backward-compatible default).
125    session: Option<DingtalkSession>,
126}
127
128impl DingtalkAuthPlugin {
129    /// Builds the plugin from node config.
130    ///
131    /// Accepted keys:
132    /// - `app_key` (string, required): DingTalk application key.
133    /// - `app_secret` (string, required): DingTalk application secret.
134    /// - `code_header` (string, default `"X-DingTalk-Code"`): header the
135    ///   authorization code is read from first (matched case-insensitively).
136    /// - `code_query` (string, default `"code"`): query parameter the code
137    ///   falls back to when the header is absent.
138    /// - `access_token_url` (string, default DingTalk's `oauth2/accessToken`).
139    /// - `userinfo_url` (string, default DingTalk's `v2/user/getuserinfo`).
140    /// - `set_userinfo_header` (bool, default `true`): when true the resolved
141    ///   userinfo JSON is base64-encoded into the `X-Userinfo` request header
142    ///   for the upstream.
143    /// - `timeout` (integer ms, default `6000`): per-callout timeout.
144    /// - `ssl_verify` (bool, default `true`): verify DingTalk's TLS certificate.
145    ///
146    /// Session-mode keys (present ⇒ session mode is enabled — see the module
147    /// docs):
148    /// - `session_secret` (string) or `session.secret` (string): signing/
149    ///   encryption secret for the session cookie. Setting it turns on the
150    ///   session flow.
151    /// - `session.cookie.name` (string, default `"dingtalk_session"`).
152    /// - `session.cookie.path` (string, default `"/"`).
153    /// - `session.cookie.lifetime` (u64 seconds, default `86400`; APISIX's
154    ///   `cookie_expires_in`).
155    /// - `session.storage` / `session.store`: server-side session backend
156    ///   (see [`server_session::parse_backend`]).
157    /// - `redirect_uri` (string, **required in session mode**): where to
158    ///   302 a browser that has neither a valid session nor a code.
159    ///
160    /// `secret_fallbacks` (APISIX multi-secret rotation) is not accepted —
161    /// see the module docs.
162    ///
163    /// ```yaml
164    /// type: dingtalk-auth
165    /// config:
166    ///   app_key: ${DINGTALK_APP_KEY}
167    ///   app_secret: ${DINGTALK_APP_SECRET}
168    ///   code_header: X-DingTalk-Code
169    ///   session:
170    ///     secret: ${DINGTALK_SESSION_SECRET}
171    ///   redirect_uri: https://login.example.com/start
172    /// ```
173    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    /// Reads the authorization code from the configured header, falling back to
254    /// the query parameter.
255    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    /// Returns a valid access token, using the in-process cache when fresh and
275    /// fetching a new one from DingTalk otherwise.
276    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    /// Exchanges the code for DingTalk userinfo using `access_token`.
311    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    /// Builds the `401` rejection and exits on the `denied` port. Reserved
337    /// for a deliberate denial — a missing code, or DingTalk actively
338    /// rejecting the code/token.
339    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    /// Builds a genuine infrastructure-failure `Err` for a DingTalk callout
354    /// that failed outright (network error, non-200, unparseable body) —
355    /// unlike `reject`, the node could not do its job rather than DingTalk
356    /// deliberately refusing the code.
357    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    /// Session-store outage: 503 through the error port. Deliberately NOT
372    /// 401 — a store outage is not "unauthenticated".
373    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    /// Builds a `302` early-exit carrying the prepared response, and exits on
392    /// the `redirect` port. Wire the node's `redirect` edge to `client.in` so
393    /// this reaches the browser.
394    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    /// Cookie attributes for the session cookie: `HttpOnly`, `SameSite=Lax`,
409    /// and `Secure` only over HTTPS (so plain-HTTP dev works).
410    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    /// Reads the session cookie via the configured backend.
421    ///
422    /// `Ok(Some(userinfo))` = a valid session; `Ok(None)` = no session (no
423    /// cookie, unopenable/expired/tampered value, or a payload that failed to
424    /// decode as JSON — in which case the session was also destroyed so a
425    /// stale entry does not linger); `Err` = store outage (503 via
426    /// [`DingtalkAuthPlugin::store_error`]), never a silent re-login.
427    ///
428    /// When the payload was undecodable, the returned delete-cookie
429    /// `Set-Cookie` value is included so the caller can forward it on
430    /// whatever response it ultimately builds.
431    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                // Undecodable payload: destroy the session and treat this
451                // request as if there were no session at all.
452                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    /// Session-mode flow: read session → callback (code) → begin login.
465    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        // Do NOT attach identity + succeed on this request: the graph wires
520        // `success` straight to `upstream.in`, and `upstream` replaces
521        // `ctx.response.headers` wholesale, so a Set-Cookie attached here
522        // would never reach the browser. Instead 302-redirect to the
523        // code-stripped URL carrying the cookie; the browser's follow-up
524        // request then hits the session-read fast path above.
525        let target = redirect_target(&ctx, &self.code_query);
526        Self::redirect(ctx, &target, vec![set_cookie])
527    }
528}
529
530/// Rebuilds the current request's path+query with the `code` query
531/// parameter stripped, for the post-establish redirect (so the browser's
532/// follow-up GET doesn't resubmit the one-time code). If the code was read
533/// from the header rather than the query string, there is nothing to strip
534/// and the query comes back unchanged. Mirrors
535/// `authz_casdoor.rs::reconstruct_uri`.
536fn 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
558/// Extracts a required string config key.
559fn 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
571/// Reads the session secret from `session_secret` or nested `session.secret`.
572fn 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
586/// Reads a string field from nested `session.cookie.<key>`, falling back to the
587/// flat `session_cookie_<key>` form (used by the UI schema).
588fn 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
599/// Reads a u64 field from nested `session.cookie.<key>`, falling back to the
600/// flat `session_cookie_<key>` form (used by the UI schema).
601fn 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
610/// Appends `key=value` to `url`, choosing `?` or `&` as needed.
611fn 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
616/// Minimal percent-encoding for query values (access tokens are URL-safe-ish
617/// but may contain `+` / `=`).
618fn 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
631/// Parses the `accessToken` from DingTalk's token-endpoint response.
632fn 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
647/// Parses DingTalk's `getuserinfo` response, returning the `result` object on
648/// `errcode == 0` and an [`DingtalkError::Unauthorized`] otherwise.
649fn 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
675/// Copies the resolved identity into `context.message` and optionally the
676/// `X-Userinfo` request header.
677fn 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        // Never let a client-supplied X-Userinfo bleed through to the upstream.
707        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        // header wins over query
804        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        // errcode != 0 → unauthorized (invalid code)
842        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        // A DingTalk callout that fails outright (here: nothing listening on
895        // the port) is a genuine infra failure and must stay a raw `Err`,
896        // unlike the deliberate `Unauthorized`/missing-code denials above.
897        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        // No session key → from_config succeeds without redirect_uri, and
917        // the existing test_missing_code_rejected_401 behavior (above) is
918        // untouched by this test file's changes.
919        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        // session.secret set but no redirect_uri → config error naming it.
927        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        // Cookie mode: hand-seal the userinfo JSON, present it, assert
967        // success + identity attached WITHOUT any HTTP callout (endpoints
968        // point at 127.0.0.1:1 — reaching them would error).
969        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    /// One-shot mock HTTP server: accepts a single connection and replies with
999    /// the given JSON body.
1000    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    /// The critical regression test for the Set-Cookie-swallowed-by-upstream
1026    /// bug: on a successful code exchange in session mode, the node must NOT
1027    /// exit on `success` (that edge feeds `upstream.in`, which replaces
1028    /// `ctx.response.headers` wholesale, dropping the Set-Cookie and causing
1029    /// an infinite login loop). It must instead 302-redirect to the
1030    /// code-stripped URL carrying the session cookie, and a follow-up
1031    /// request presenting that cookie must take the session-read fast path.
1032    #[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        // No identity should be attached on this response — it's a bare
1078        // redirect, not a success.
1079        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        // Follow-up request presenting the cookie the redirect just set must
1084        // take the fast path: success, identity attached, no callout.
1085        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    /// In redis mode a valid session cookie authenticates from the store
1107    /// without any DingTalk callout, and a store outage is a 503 on the
1108    /// error port — never a silent re-login.
1109    #[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        // Hand-put sealed userinfo JSON under an id, the way a successful
1130        // callback would have established it.
1131        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        // Outage: same request, failing store.
1158        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}