Skip to main content

featherbit/plugins/native/
feishu_auth.rs

1//! Feishu / Lark authentication plugin (`feishu-auth`).
2//!
3//! Validates a Feishu authorization *code* by exchanging it, through Feishu's
4//! OAuth v2 token endpoint, for a user access token, then calls Feishu's
5//! userinfo endpoint to resolve the calling user's identity and attaches it to
6//! the request. A code that cannot be resolved is rejected with a `401`.
7//!
8//! # Ported subset / deviations from APISIX
9//!
10//! APISIX's `feishu-auth` is a *session* plugin: it caches the exchanged access
11//! token and resolved userinfo in an encrypted `feishu_session` cookie so later
12//! requests skip the callouts, and it 302-redirects to `redirect_uri` when no
13//! code/session is present. featherbit is stateless with no session store, so
14//! this port implements the **token-validation subset**: every request must
15//! carry a code, which is exchanged and validated on each request. The session
16//! / cookie / redirect machinery is dropped, along with the keys that only
17//! served it (`secret`, `secret_fallbacks`, `redirect_uri`, `cookie_expires_in`).
18//! `auth_redirect_uri` is retained because it is part of the `authorization_code`
19//! token-exchange body, not the interactive redirect.
20
21use async_trait::async_trait;
22use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
23use base64::Engine;
24use bytes::Bytes;
25use std::collections::HashMap;
26use std::sync::Arc;
27use std::time::Duration;
28
29use crate::context::{Context, GatewayError};
30use crate::outbound::{OutboundRequest, OutboundResponse};
31use crate::plugins::resources::PluginResources;
32use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
33
34const DEFAULT_TOKEN_URL: &str = "https://open.feishu.cn/open-apis/authen/v2/oauth/token";
35const DEFAULT_USERINFO_URL: &str = "https://open.feishu.cn/open-apis/authen/v1/user_info";
36
37/// Outcome of resolving a Feishu code. Every failure maps to a `401`
38/// (`FEISHU_AUTH_FAILED`); variants exist to keep the reason legible.
39#[derive(Debug)]
40enum FeishuError {
41    Unauthorized(String),
42    Upstream(String),
43}
44
45impl FeishuError {
46    fn message(&self) -> &str {
47        match self {
48            FeishuError::Unauthorized(m) | FeishuError::Upstream(m) => m,
49        }
50    }
51}
52
53/// Authenticates requests by exchanging a Feishu authorization code for a user
54/// access token, then resolving that token to a Feishu user.
55pub struct FeishuAuthPlugin {
56    app_id: String,
57    app_secret: String,
58    auth_redirect_uri: String,
59    code_header: String,
60    code_query: String,
61    token_url: String,
62    userinfo_url: String,
63    set_userinfo_header: bool,
64    timeout: Duration,
65    ssl_verify: bool,
66    resources: Arc<PluginResources>,
67}
68
69impl FeishuAuthPlugin {
70    /// Builds the plugin from node config.
71    ///
72    /// Accepted keys:
73    /// - `app_id` (string, required): Feishu application id.
74    /// - `app_secret` (string, required): Feishu application secret.
75    /// - `auth_redirect_uri` (string, required): the `redirect_uri` registered
76    ///   with Feishu; sent in the `authorization_code` token-exchange body and
77    ///   must match the one used to obtain the code.
78    /// - `code_header` (string, default `"X-Feishu-Code"`): header the code is
79    ///   read from first (matched case-insensitively).
80    /// - `code_query` (string, default `"code"`): query parameter fallback.
81    /// - `access_token_url` (string, default Feishu's `oauth/token`).
82    /// - `userinfo_url` (string, default Feishu's `authen/v1/user_info`).
83    /// - `set_userinfo_header` (bool, default `true`): base64-encode the
84    ///   resolved userinfo into the `X-Userinfo` request header.
85    /// - `timeout` (integer ms, default `6000`).
86    /// - `ssl_verify` (bool, default `true`).
87    ///
88    /// Session-only APISIX keys (`secret`, `secret_fallbacks`, `redirect_uri`,
89    /// `cookie_expires_in`) are not accepted — see the module docs.
90    ///
91    /// ```yaml
92    /// type: feishu-auth
93    /// config:
94    ///   app_id: ${FEISHU_APP_ID}
95    ///   app_secret: ${FEISHU_APP_SECRET}
96    ///   auth_redirect_uri: https://app.example.com/callback
97    /// ```
98    pub fn from_config(
99        config: &HashMap<String, serde_json::Value>,
100        resources: &Arc<PluginResources>,
101    ) -> Result<Self, String> {
102        let app_id = require_string(config, "app_id")?;
103        let app_secret = require_string(config, "app_secret")?;
104        let auth_redirect_uri = require_string(config, "auth_redirect_uri")?;
105
106        let code_header = config
107            .get("code_header")
108            .and_then(|v| v.as_str())
109            .unwrap_or("X-Feishu-Code")
110            .to_lowercase();
111        let code_query = config
112            .get("code_query")
113            .and_then(|v| v.as_str())
114            .unwrap_or("code")
115            .to_string();
116        let token_url = config
117            .get("access_token_url")
118            .and_then(|v| v.as_str())
119            .unwrap_or(DEFAULT_TOKEN_URL)
120            .to_string();
121        let userinfo_url = config
122            .get("userinfo_url")
123            .and_then(|v| v.as_str())
124            .unwrap_or(DEFAULT_USERINFO_URL)
125            .to_string();
126        let set_userinfo_header = config
127            .get("set_userinfo_header")
128            .and_then(|v| v.as_bool())
129            .unwrap_or(true);
130        let timeout = Duration::from_millis(
131            config
132                .get("timeout")
133                .and_then(|v| v.as_u64())
134                .unwrap_or(6000),
135        );
136        let ssl_verify = config
137            .get("ssl_verify")
138            .and_then(|v| v.as_bool())
139            .unwrap_or(true);
140
141        Ok(Self {
142            app_id,
143            app_secret,
144            auth_redirect_uri,
145            code_header,
146            code_query,
147            token_url,
148            userinfo_url,
149            set_userinfo_header,
150            timeout,
151            ssl_verify,
152            resources: resources.clone(),
153        })
154    }
155
156    fn extract_code(&self, ctx: &Context) -> Option<String> {
157        if let Some(v) = ctx
158            .request
159            .headers
160            .get(&self.code_header)
161            .and_then(|v| v.first())
162        {
163            if !v.is_empty() {
164                return Some(v.clone());
165            }
166        }
167        ctx.request
168            .query_params
169            .get(&self.code_query)
170            .and_then(|v| v.first())
171            .filter(|v| !v.is_empty())
172            .cloned()
173    }
174
175    /// Exchanges `code` for a Feishu user access token.
176    async fn fetch_access_token(&self, code: &str) -> Result<String, FeishuError> {
177        let body = self.token_request_body(code);
178        let req = OutboundRequest {
179            method: http::Method::POST,
180            url: self.token_url.clone(),
181            headers: vec![("content-type".to_string(), "application/json".to_string())],
182            body: Bytes::from(serde_json::to_vec(&body).unwrap_or_default()),
183            timeout: self.timeout,
184            ssl_verify: self.ssl_verify,
185            tls: None,
186        };
187        let resp = self
188            .resources
189            .outbound
190            .request(req)
191            .await
192            .map_err(|e| FeishuError::Upstream(format!("token callout failed: {}", e)))?;
193        parse_access_token(&resp)
194    }
195
196    /// Builds the `authorization_code` token-exchange body.
197    fn token_request_body(&self, code: &str) -> serde_json::Value {
198        serde_json::json!({
199            "grant_type": "authorization_code",
200            "client_id": self.app_id,
201            "client_secret": self.app_secret,
202            "redirect_uri": self.auth_redirect_uri,
203            "code": code,
204        })
205    }
206
207    /// Resolves the access token to Feishu userinfo.
208    async fn fetch_userinfo(&self, access_token: &str) -> Result<serde_json::Value, FeishuError> {
209        let req = OutboundRequest {
210            method: http::Method::GET,
211            url: self.userinfo_url.clone(),
212            headers: vec![
213                ("content-type".to_string(), "application/json".to_string()),
214                (
215                    "authorization".to_string(),
216                    format!("Bearer {}", access_token),
217                ),
218            ],
219            body: Bytes::new(),
220            timeout: self.timeout,
221            ssl_verify: self.ssl_verify,
222            tls: None,
223        };
224        let resp = self
225            .resources
226            .outbound
227            .request(req)
228            .await
229            .map_err(|e| FeishuError::Upstream(format!("userinfo callout failed: {}", e)))?;
230        parse_userinfo(&resp)
231    }
232
233    fn reject(ctx: Context, message: &str) -> PluginResult {
234        let mut ctx = ctx;
235        ctx.response.status_code = 401;
236        ctx.response.body = Bytes::from(format!(
237            r#"{{"error": "unauthorized", "message": "{}"}}"#,
238            message.replace('"', "'")
239        ));
240        ctx.response.headers.insert(
241            "content-type".to_string(),
242            vec!["application/json".to_string()],
243        );
244        Err(PluginExecutionError {
245            context: ctx,
246            error: GatewayError {
247                node_id: String::new(),
248                code: "FEISHU_AUTH_FAILED".to_string(),
249                message: message.to_string(),
250                metadata: HashMap::new(),
251            },
252        })
253    }
254}
255
256fn require_string(
257    config: &HashMap<String, serde_json::Value>,
258    key: &str,
259) -> Result<String, String> {
260    config
261        .get(key)
262        .and_then(|v| v.as_str())
263        .filter(|s| !s.is_empty())
264        .map(String::from)
265        .ok_or_else(|| format!("feishu-auth plugin requires '{}'", key))
266}
267
268/// Parses the user access token from Feishu's v2 token response.
269fn parse_access_token(resp: &OutboundResponse) -> Result<String, FeishuError> {
270    if resp.status != 200 {
271        return Err(FeishuError::Upstream(format!(
272            "unexpected token response status: {}",
273            resp.status
274        )));
275    }
276    let data: serde_json::Value = serde_json::from_slice(&resp.body)
277        .map_err(|e| FeishuError::Upstream(format!("failed to decode token response: {}", e)))?;
278    // Feishu returns `code: 0` on success for the v2 token endpoint; a non-zero
279    // code (e.g. bad/expired authorization code) is an auth failure.
280    if let Some(code) = data.get("code").and_then(|v| v.as_i64()) {
281        if code != 0 {
282            let msg = data
283                .get("error_description")
284                .and_then(|v| v.as_str())
285                .or_else(|| data.get("msg").and_then(|v| v.as_str()))
286                .unwrap_or("unknown");
287            return Err(FeishuError::Unauthorized(format!(
288                "feishu rejected code (code {}): {}",
289                code, msg
290            )));
291        }
292    }
293    data.get("access_token")
294        .and_then(|v| v.as_str())
295        .map(String::from)
296        .ok_or_else(|| FeishuError::Unauthorized("token response missing access_token".to_string()))
297}
298
299/// Parses Feishu's userinfo response, returning `data.data` on `code == 0`.
300fn parse_userinfo(resp: &OutboundResponse) -> Result<serde_json::Value, FeishuError> {
301    if resp.status != 200 {
302        return Err(FeishuError::Upstream(format!(
303            "unexpected userinfo response status: {}",
304            resp.status
305        )));
306    }
307    let data: serde_json::Value = serde_json::from_slice(&resp.body)
308        .map_err(|e| FeishuError::Upstream(format!("failed to decode userinfo response: {}", e)))?;
309    let code = data.get("code").and_then(|v| v.as_i64()).unwrap_or(-1);
310    if code != 0 {
311        let msg = data
312            .get("msg")
313            .and_then(|v| v.as_str())
314            .unwrap_or("unknown");
315        return Err(FeishuError::Unauthorized(format!(
316            "feishu userinfo rejected token (code {}): {}",
317            code, msg
318        )));
319    }
320    data.get("data")
321        .cloned()
322        .ok_or_else(|| FeishuError::Upstream("userinfo response missing data".to_string()))
323}
324
325/// Copies the resolved identity into `context.message` and optionally the
326/// `X-Userinfo` request header.
327fn attach_identity(ctx: &mut Context, userinfo: &serde_json::Value, set_header: bool) {
328    ctx.message
329        .insert("feishu_userinfo".to_string(), userinfo.clone());
330    if let Some(uid) = userinfo
331        .get("user_id")
332        .or_else(|| userinfo.get("open_id"))
333        .or_else(|| userinfo.get("union_id"))
334        .and_then(|v| v.as_str())
335    {
336        ctx.message.insert(
337            "user_id".to_string(),
338            serde_json::Value::String(uid.to_string()),
339        );
340    }
341    if set_header {
342        if let Ok(raw) = serde_json::to_vec(userinfo) {
343            ctx.request
344                .headers
345                .insert("x-userinfo".to_string(), vec![BASE64_STANDARD.encode(raw)]);
346        }
347    }
348}
349
350#[async_trait]
351impl Plugin for FeishuAuthPlugin {
352    fn plugin_type(&self) -> &str {
353        "feishu-auth"
354    }
355
356    async fn execute(
357        &self,
358        mut ctx: Context,
359        _named_inputs: &HashMap<String, serde_json::Value>,
360    ) -> PluginResult {
361        ctx.request.headers.remove("x-userinfo");
362
363        let code = match self.extract_code(&ctx) {
364            Some(c) => c,
365            None => return Self::reject(ctx, "Missing Feishu authorization code"),
366        };
367
368        let access_token = match self.fetch_access_token(&code).await {
369            Ok(t) => t,
370            Err(e) => return Self::reject(ctx, e.message()),
371        };
372
373        let userinfo = match self.fetch_userinfo(&access_token).await {
374            Ok(u) => u,
375            Err(e) => return Self::reject(ctx, e.message()),
376        };
377
378        attach_identity(&mut ctx, &userinfo, self.set_userinfo_header);
379        Ok(PluginOutput {
380            context: ctx,
381            named_outputs: HashMap::new(),
382        })
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
390
391    fn resp(status: u16, body: serde_json::Value) -> OutboundResponse {
392        OutboundResponse {
393            status,
394            headers: HashMap::new(),
395            body: Bytes::from(serde_json::to_vec(&body).unwrap()),
396        }
397    }
398
399    fn base_ctx() -> Context {
400        Context {
401            request: GatewayRequest {
402                method: "GET".to_string(),
403                path: "/".to_string(),
404                host: "h".to_string(),
405                scheme: "http".to_string(),
406                headers: HashMap::new(),
407                query_params: HashMap::new(),
408                body: Bytes::new(),
409                remote_addr: "1.2.3.4:5".to_string(),
410                protocol: Protocol::Http1,
411            },
412            response: GatewayResponse {
413                status_code: 0,
414                headers: HashMap::new(),
415                body: Bytes::new(),
416            },
417            message: HashMap::new(),
418            errors: Vec::new(),
419        }
420    }
421
422    fn full_cfg() -> HashMap<String, serde_json::Value> {
423        [
424            ("app_id", "id"),
425            ("app_secret", "secret"),
426            ("auth_redirect_uri", "https://app/callback"),
427        ]
428        .iter()
429        .map(|(k, v)| (k.to_string(), serde_json::Value::String(v.to_string())))
430        .collect()
431    }
432
433    #[test]
434    fn test_requires_id_secret_redirect() {
435        assert!(FeishuAuthPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err());
436        // missing auth_redirect_uri
437        let mut cfg: HashMap<String, serde_json::Value> = HashMap::new();
438        cfg.insert("app_id".to_string(), serde_json::json!("id"));
439        cfg.insert("app_secret".to_string(), serde_json::json!("secret"));
440        assert!(FeishuAuthPlugin::from_config(&cfg, &PluginResources::empty()).is_err());
441        assert!(FeishuAuthPlugin::from_config(&full_cfg(), &PluginResources::empty()).is_ok());
442    }
443
444    #[test]
445    fn test_token_request_body_shape() {
446        let plugin = FeishuAuthPlugin::from_config(&full_cfg(), &PluginResources::empty()).unwrap();
447        let body = plugin.token_request_body("the-code");
448        assert_eq!(body.get("grant_type").unwrap(), "authorization_code");
449        assert_eq!(body.get("client_id").unwrap(), "id");
450        assert_eq!(body.get("client_secret").unwrap(), "secret");
451        assert_eq!(body.get("redirect_uri").unwrap(), "https://app/callback");
452        assert_eq!(body.get("code").unwrap(), "the-code");
453    }
454
455    #[test]
456    fn test_parse_access_token() {
457        let ok = resp(
458            200,
459            serde_json::json!({ "code": 0, "access_token": "tok", "expires_in": 7200 }),
460        );
461        assert_eq!(parse_access_token(&ok).unwrap(), "tok");
462
463        // non-zero code → unauthorized
464        let denied = resp(
465            200,
466            serde_json::json!({ "code": 20037, "error_description": "invalid code" }),
467        );
468        assert!(matches!(
469            parse_access_token(&denied),
470            Err(FeishuError::Unauthorized(_))
471        ));
472
473        let bad_status = resp(400, serde_json::json!({}));
474        assert!(matches!(
475            parse_access_token(&bad_status),
476            Err(FeishuError::Upstream(_))
477        ));
478    }
479
480    #[test]
481    fn test_parse_userinfo() {
482        let ok = resp(
483            200,
484            serde_json::json!({ "code": 0, "data": { "user_id": "u1", "name": "Bob" } }),
485        );
486        let data = parse_userinfo(&ok).unwrap();
487        assert_eq!(data.get("user_id").unwrap(), "u1");
488
489        let denied = resp(
490            200,
491            serde_json::json!({ "code": 99991663, "msg": "token invalid" }),
492        );
493        assert!(matches!(
494            parse_userinfo(&denied),
495            Err(FeishuError::Unauthorized(_))
496        ));
497    }
498
499    #[test]
500    fn test_attach_identity() {
501        let mut ctx = base_ctx();
502        let userinfo = serde_json::json!({ "user_id": "u1", "open_id": "ou_x", "name": "Bob" });
503        attach_identity(&mut ctx, &userinfo, true);
504        assert_eq!(ctx.message.get("user_id").unwrap(), "u1");
505        assert!(ctx.request.headers.contains_key("x-userinfo"));
506    }
507
508    #[tokio::test]
509    async fn test_missing_code_rejected_401() {
510        let plugin = FeishuAuthPlugin::from_config(&full_cfg(), &PluginResources::empty()).unwrap();
511        let err = plugin
512            .execute(base_ctx(), &HashMap::new())
513            .await
514            .unwrap_err();
515        assert_eq!(err.context.response.status_code, 401);
516        assert_eq!(err.error.code, "FEISHU_AUTH_FAILED");
517    }
518}