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 missing code, or a code/token Feishu actively rejects, is
7//! denied with a `401` on the `denied` port; a Feishu callout that fails
8//! outright (network error, non-200, unparseable body) is a genuine
9//! infrastructure failure and stays on the `error` port.
10//!
11//! # Ported subset / deviations from APISIX
12//!
13//! APISIX's `feishu-auth` is a *session* plugin: on the first request it
14//! reads a code, exchanges it for a user access token and userinfo, then
15//! stores both in an encrypted `feishu_session` cookie so later requests skip
16//! the callouts, and it 302-redirects to `redirect_uri` when no code and no
17//! session are present. That session machinery is now restored on an opt-in
18//! basis, sharing the same [cookie](crate::plugins::util::cookie_session) /
19//! [server-store](crate::plugins::util::server_session) primitives as
20//! `cas-auth`/`openid-connect`/`authz-casdoor`/`dingtalk-auth`:
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 exchanged and validated against Feishu on each request. No cookie is
25//!   read or 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 `feishu_session` cookie (cookie mode: the session payload 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//!   payload lives server-side). A valid session attaches identity straight
32//!   from the stored userinfo — no Feishu 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, distinct from the
36//!   always-required `auth_redirect_uri` token-exchange field below). A code
37//!   present runs the existing token+userinfo callouts unchanged, then
38//!   establishes a new session (payload = userinfo JSON plus the exchanged
39//!   access token and its expiry; subject = `user_id` else `open_id` else
40//!   `union_id` else empty; ttl = `session.cookie.lifetime`, APISIX's
41//!   `cookie_expires_in`, default `86400`) and 302-redirects (the `redirect`
42//!   port) to the current URL with the `code` query parameter stripped,
43//!   carrying the session `Set-Cookie` — the graph wires `success` straight
44//!   to `upstream.in`, which replaces `ctx.response.headers` wholesale, so a
45//!   Set-Cookie attached on that path would never reach the browser. The
46//!   browser's follow-up request then hits the session-read fast path above
47//!   and attaches identity. A session-store outage is a `503`
48//!   (`SESSION_STORE_ERROR`) on the `error` port, never a silent re-login.
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. `auth_redirect_uri`
53//! is retained (and still always required) because it is part of the
54//! `authorization_code` token-exchange body, not the interactive redirect.
55
56use async_trait::async_trait;
57use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
58use base64::Engine;
59use bytes::Bytes;
60use std::collections::HashMap;
61use std::sync::Arc;
62use std::time::{Duration, SystemTime, UNIX_EPOCH};
63
64use crate::context::{Context, GatewayError};
65use crate::outbound::{OutboundRequest, OutboundResponse};
66use crate::plugins::resources::PluginResources;
67use crate::plugins::util::cookie_session::{read_cookie, CookieAttrs, CookieSealer, SameSite};
68use crate::plugins::util::server_session::{self, SessionBackend};
69use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
70use crate::sessions::StoreError;
71
72const DEFAULT_TOKEN_URL: &str = "https://open.feishu.cn/open-apis/authen/v2/oauth/token";
73const DEFAULT_USERINFO_URL: &str = "https://open.feishu.cn/open-apis/authen/v1/user_info";
74
75/// Outcome of resolving a Feishu code. `Unauthorized` is a deliberate denial
76/// (exits `denied`, `401`); `Upstream` is a genuine callout failure (exits
77/// `error`).
78#[derive(Debug)]
79enum FeishuError {
80    Unauthorized(String),
81    Upstream(String),
82}
83
84impl FeishuError {
85    fn message(&self) -> &str {
86        match self {
87            FeishuError::Unauthorized(m) | FeishuError::Upstream(m) => m,
88        }
89    }
90}
91
92/// Session payload: the resolved userinfo plus the exchanged access token
93/// and its expiry, cached per APISIX's original so a future re-exchange
94/// (after a userinfo failure) can reuse a still-valid token.
95#[derive(serde::Serialize, serde::Deserialize)]
96struct FeishuSessionData {
97    userinfo: serde_json::Value,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    access_token: Option<String>,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    access_token_expires_at: Option<u64>,
102}
103
104/// Session-mode settings (present when `session.secret` is configured).
105struct FeishuSession {
106    sealer: CookieSealer,
107    backend: SessionBackend,
108    cookie_name: String,
109    cookie_path: String,
110    cookie_lifetime: u64,
111    redirect_uri: String,
112}
113
114/// Authenticates requests by exchanging a Feishu authorization code for a user
115/// access token, then resolving that token to a Feishu user.
116pub struct FeishuAuthPlugin {
117    app_id: String,
118    app_secret: String,
119    auth_redirect_uri: String,
120    code_header: String,
121    code_query: String,
122    token_url: String,
123    userinfo_url: String,
124    set_userinfo_header: bool,
125    timeout: Duration,
126    ssl_verify: bool,
127    resources: Arc<PluginResources>,
128    /// Session-mode settings; `None` keeps the stateless token-validation
129    /// behavior (the pre-existing, backward-compatible default).
130    session: Option<FeishuSession>,
131}
132
133impl FeishuAuthPlugin {
134    /// Builds the plugin from node config.
135    ///
136    /// Accepted keys:
137    /// - `app_id` (string, required): Feishu application id.
138    /// - `app_secret` (string, required): Feishu application secret.
139    /// - `auth_redirect_uri` (string, required): the `redirect_uri` registered
140    ///   with Feishu; sent in the `authorization_code` token-exchange body and
141    ///   must match the one used to obtain the code.
142    /// - `code_header` (string, default `"X-Feishu-Code"`): header the code is
143    ///   read from first (matched case-insensitively).
144    /// - `code_query` (string, default `"code"`): query parameter fallback.
145    /// - `access_token_url` (string, default Feishu's `oauth/token`).
146    /// - `userinfo_url` (string, default Feishu's `authen/v1/user_info`).
147    /// - `set_userinfo_header` (bool, default `true`): base64-encode the
148    ///   resolved userinfo into the `X-Userinfo` request header.
149    /// - `timeout` (integer ms, default `6000`).
150    /// - `ssl_verify` (bool, default `true`).
151    ///
152    /// Session-mode keys (present ⇒ session mode is enabled — see the module
153    /// docs):
154    /// - `session_secret` (string) or `session.secret` (string): signing/
155    ///   encryption secret for the session cookie. Setting it turns on the
156    ///   session flow.
157    /// - `session.cookie.name` (string, default `"feishu_session"`).
158    /// - `session.cookie.path` (string, default `"/"`).
159    /// - `session.cookie.lifetime` (u64 seconds, default `86400`; APISIX's
160    ///   `cookie_expires_in`).
161    /// - `session.storage` / `session.store`: server-side session backend
162    ///   (see [`server_session::parse_backend`]).
163    /// - `redirect_uri` (string, **required in session mode**): where to
164    ///   302 a browser that has neither a valid session nor a code. Distinct
165    ///   from `auth_redirect_uri`, which stays required always.
166    ///
167    /// `secret_fallbacks` (APISIX multi-secret rotation) is not accepted —
168    /// see the module docs.
169    ///
170    /// ```yaml
171    /// type: feishu-auth
172    /// config:
173    ///   app_id: ${FEISHU_APP_ID}
174    ///   app_secret: ${FEISHU_APP_SECRET}
175    ///   auth_redirect_uri: https://app.example.com/callback
176    ///   session:
177    ///     secret: ${FEISHU_SESSION_SECRET}
178    ///   redirect_uri: https://login.example.com/start
179    /// ```
180    pub fn from_config(
181        config: &HashMap<String, serde_json::Value>,
182        resources: &Arc<PluginResources>,
183    ) -> Result<Self, String> {
184        let app_id = require_string(config, "app_id")?;
185        let app_secret = require_string(config, "app_secret")?;
186        let auth_redirect_uri = require_string(config, "auth_redirect_uri")?;
187
188        let code_header = config
189            .get("code_header")
190            .and_then(|v| v.as_str())
191            .unwrap_or("X-Feishu-Code")
192            .to_lowercase();
193        let code_query = config
194            .get("code_query")
195            .and_then(|v| v.as_str())
196            .unwrap_or("code")
197            .to_string();
198        let token_url = config
199            .get("access_token_url")
200            .and_then(|v| v.as_str())
201            .unwrap_or(DEFAULT_TOKEN_URL)
202            .to_string();
203        let userinfo_url = config
204            .get("userinfo_url")
205            .and_then(|v| v.as_str())
206            .unwrap_or(DEFAULT_USERINFO_URL)
207            .to_string();
208        let set_userinfo_header = config
209            .get("set_userinfo_header")
210            .and_then(|v| v.as_bool())
211            .unwrap_or(true);
212        let timeout = Duration::from_millis(
213            config
214                .get("timeout")
215                .and_then(|v| v.as_u64())
216                .unwrap_or(6000),
217        );
218        let ssl_verify = config
219            .get("ssl_verify")
220            .and_then(|v| v.as_bool())
221            .unwrap_or(true);
222
223        let session = match session_secret(config) {
224            Some(secret) => {
225                let redirect_uri = require_string(config, "redirect_uri")?;
226                let sealer = CookieSealer::new(&secret);
227                let cookie_name = session_cookie_str(config, "name")
228                    .unwrap_or_else(|| "feishu_session".to_string());
229                let cookie_path =
230                    session_cookie_str(config, "path").unwrap_or_else(|| "/".to_string());
231                let cookie_lifetime = session_cookie_u64(config, "lifetime").unwrap_or(86_400);
232                let backend = server_session::parse_backend(config, resources, "feishu-auth")?;
233                Some(FeishuSession {
234                    sealer,
235                    backend,
236                    cookie_name,
237                    cookie_path,
238                    cookie_lifetime,
239                    redirect_uri,
240                })
241            }
242            None => None,
243        };
244
245        Ok(Self {
246            app_id,
247            app_secret,
248            auth_redirect_uri,
249            code_header,
250            code_query,
251            token_url,
252            userinfo_url,
253            set_userinfo_header,
254            timeout,
255            ssl_verify,
256            resources: resources.clone(),
257            session,
258        })
259    }
260
261    fn extract_code(&self, ctx: &Context) -> Option<String> {
262        if let Some(v) = ctx
263            .request
264            .headers
265            .get(&self.code_header)
266            .and_then(|v| v.first())
267        {
268            if !v.is_empty() {
269                return Some(v.clone());
270            }
271        }
272        ctx.request
273            .query_params
274            .get(&self.code_query)
275            .and_then(|v| v.first())
276            .filter(|v| !v.is_empty())
277            .cloned()
278    }
279
280    /// Exchanges `code` for a Feishu user access token, returning the token
281    /// and (when present) its `expires_in` seconds.
282    async fn fetch_access_token(&self, code: &str) -> Result<(String, Option<u64>), FeishuError> {
283        let body = self.token_request_body(code);
284        let req = OutboundRequest {
285            method: http::Method::POST,
286            url: self.token_url.clone(),
287            headers: vec![("content-type".to_string(), "application/json".to_string())],
288            body: Bytes::from(serde_json::to_vec(&body).unwrap_or_default()),
289            timeout: self.timeout,
290            ssl_verify: self.ssl_verify,
291            tls: None,
292        };
293        let resp = self
294            .resources
295            .outbound
296            .request(req)
297            .await
298            .map_err(|e| FeishuError::Upstream(format!("token callout failed: {}", e)))?;
299        parse_access_token(&resp)
300    }
301
302    /// Builds the `authorization_code` token-exchange body.
303    fn token_request_body(&self, code: &str) -> serde_json::Value {
304        serde_json::json!({
305            "grant_type": "authorization_code",
306            "client_id": self.app_id,
307            "client_secret": self.app_secret,
308            "redirect_uri": self.auth_redirect_uri,
309            "code": code,
310        })
311    }
312
313    /// Resolves the access token to Feishu userinfo.
314    async fn fetch_userinfo(&self, access_token: &str) -> Result<serde_json::Value, FeishuError> {
315        let req = OutboundRequest {
316            method: http::Method::GET,
317            url: self.userinfo_url.clone(),
318            headers: vec![
319                ("content-type".to_string(), "application/json".to_string()),
320                (
321                    "authorization".to_string(),
322                    format!("Bearer {}", access_token),
323                ),
324            ],
325            body: Bytes::new(),
326            timeout: self.timeout,
327            ssl_verify: self.ssl_verify,
328            tls: None,
329        };
330        let resp = self
331            .resources
332            .outbound
333            .request(req)
334            .await
335            .map_err(|e| FeishuError::Upstream(format!("userinfo callout failed: {}", e)))?;
336        parse_userinfo(&resp)
337    }
338
339    /// Builds the `401` rejection and exits on the `denied` port. Reserved
340    /// for a deliberate denial — a missing code, or Feishu actively
341    /// rejecting the code/token.
342    fn reject(ctx: Context, message: &str) -> PluginResult {
343        let mut ctx = ctx;
344        ctx.response.status_code = 401;
345        ctx.response.body = Bytes::from(format!(
346            r#"{{"error": "unauthorized", "message": "{}"}}"#,
347            message.replace('"', "'")
348        ));
349        ctx.response.headers.insert(
350            "content-type".to_string(),
351            vec!["application/json".to_string()],
352        );
353        Ok(PluginOutput::on_port(ctx, "denied"))
354    }
355
356    /// Builds a genuine infrastructure-failure `Err` for a Feishu callout
357    /// that failed outright (network error, non-200, unparseable body) —
358    /// unlike `reject`, the node could not do its job rather than Feishu
359    /// deliberately refusing the code.
360    fn upstream_error(ctx: Context, message: &str) -> PluginResult {
361        let mut ctx = ctx;
362        ctx.response.status_code = 502;
363        Err(PluginExecutionError {
364            context: ctx,
365            error: GatewayError {
366                node_id: String::new(),
367                code: "FEISHU_UPSTREAM_ERROR".to_string(),
368                message: message.to_string(),
369                metadata: HashMap::new(),
370            },
371        })
372    }
373
374    /// Session-store outage: 503 through the error port. Deliberately NOT
375    /// 401 — a store outage is not "unauthenticated".
376    fn store_error(mut ctx: Context, e: StoreError) -> PluginExecutionError {
377        ctx.response.status_code = 503;
378        ctx.response.body = Bytes::from(r#"{"error": "session store unavailable"}"#.as_bytes());
379        ctx.response.headers.insert(
380            "content-type".to_string(),
381            vec!["application/json".to_string()],
382        );
383        PluginExecutionError {
384            context: ctx,
385            error: GatewayError {
386                node_id: String::new(),
387                code: "SESSION_STORE_ERROR".to_string(),
388                message: e.to_string(),
389                metadata: HashMap::new(),
390            },
391        }
392    }
393
394    /// Builds a `302` early-exit carrying the prepared response, and exits on
395    /// the `redirect` port. Wire the node's `redirect` edge to `client.in` so
396    /// this reaches the browser.
397    fn redirect(mut ctx: Context, location: &str, set_cookies: Vec<String>) -> PluginResult {
398        ctx.response.status_code = 302;
399        ctx.response.body = Bytes::new();
400        ctx.response
401            .headers
402            .insert("location".to_string(), vec![location.to_string()]);
403        if !set_cookies.is_empty() {
404            ctx.response
405                .headers
406                .insert("set-cookie".to_string(), set_cookies);
407        }
408        Ok(PluginOutput::on_port(ctx, "redirect"))
409    }
410
411    /// Cookie attributes for the session cookie: `HttpOnly`, `SameSite=Lax`,
412    /// and `Secure` only over HTTPS (so plain-HTTP dev works).
413    fn session_attrs<'a>(session: &'a FeishuSession, ctx: &Context) -> CookieAttrs<'a> {
414        CookieAttrs {
415            path: &session.cookie_path,
416            max_age: Some(session.cookie_lifetime),
417            http_only: true,
418            secure: ctx.request.scheme == "https",
419            same_site: SameSite::Lax,
420        }
421    }
422
423    /// Reads the session cookie via the configured backend.
424    ///
425    /// `Ok(Some(data))` = a valid session; `Ok(None)` = no session (no
426    /// cookie, unopenable/expired/tampered value, or a payload that failed to
427    /// decode as JSON — in which case the session was also destroyed so a
428    /// stale entry does not linger); `Err` = store outage (503 via
429    /// [`FeishuAuthPlugin::store_error`]), never a silent re-login.
430    ///
431    /// When the payload was undecodable, the returned delete-cookie
432    /// `Set-Cookie` value is included so the caller can forward it on
433    /// whatever response it ultimately builds.
434    async fn read_session(
435        &self,
436        ctx: &Context,
437        session: &FeishuSession,
438    ) -> Result<(Option<FeishuSessionData>, Option<String>), StoreError> {
439        let Some(cookie_header) = ctx.request.headers.get("cookie").and_then(|v| v.first()) else {
440            return Ok((None, None));
441        };
442        let Some(raw) = read_cookie(cookie_header, &session.cookie_name) else {
443            return Ok((None, None));
444        };
445        let raw = raw.to_string();
446        let Some(bytes) = server_session::load(&session.backend, &session.sealer, &raw).await?
447        else {
448            return Ok((None, None));
449        };
450        match serde_json::from_slice::<FeishuSessionData>(&bytes) {
451            Ok(data) => Ok((Some(data), None)),
452            Err(_) => {
453                // Undecodable payload: destroy the session and treat this
454                // request as if there were no session at all.
455                let cleared = server_session::destroy(
456                    &session.backend,
457                    Some(&raw),
458                    &session.cookie_name,
459                    &session.cookie_path,
460                )
461                .await?;
462                Ok((None, Some(cleared)))
463            }
464        }
465    }
466
467    /// Session-mode flow: read session → callback (code) → begin login.
468    async fn execute_session(&self, mut ctx: Context, session: &FeishuSession) -> PluginResult {
469        let cleared_cookie = match self.read_session(&ctx, session).await {
470            Ok((Some(data), _)) => {
471                attach_identity(&mut ctx, &data.userinfo, self.set_userinfo_header);
472                return Ok(PluginOutput::success(ctx));
473            }
474            Ok((None, cleared)) => cleared,
475            Err(e) => return Err(Self::store_error(ctx, e)),
476        };
477
478        let code = match self.extract_code(&ctx) {
479            Some(c) => c,
480            None => {
481                let set_cookies = cleared_cookie.into_iter().collect();
482                return Self::redirect(ctx, &session.redirect_uri, set_cookies);
483            }
484        };
485
486        let (access_token, expires_in) = match self.fetch_access_token(&code).await {
487            Ok(t) => t,
488            Err(FeishuError::Unauthorized(m)) => return Self::reject(ctx, &m),
489            Err(e @ FeishuError::Upstream(_)) => return Self::upstream_error(ctx, e.message()),
490        };
491
492        let userinfo = match self.fetch_userinfo(&access_token).await {
493            Ok(u) => u,
494            Err(FeishuError::Unauthorized(m)) => return Self::reject(ctx, &m),
495            Err(e @ FeishuError::Upstream(_)) => return Self::upstream_error(ctx, e.message()),
496        };
497
498        let subject = userinfo
499            .get("user_id")
500            .or_else(|| userinfo.get("open_id"))
501            .or_else(|| userinfo.get("union_id"))
502            .and_then(|v| v.as_str())
503            .unwrap_or("");
504        let ttl = Duration::from_secs(session.cookie_lifetime);
505        let meta = server_session::meta_now(&ctx, "feishu-auth", subject, ttl);
506        // APISIX's skew: cache the app access token slightly short of its
507        // real expiry.
508        let access_token_expires_at = expires_in.map(|secs| now_unix() + secs.saturating_sub(60));
509        let session_data = FeishuSessionData {
510            userinfo: userinfo.clone(),
511            access_token: Some(access_token),
512            access_token_expires_at,
513        };
514        let payload = serde_json::to_vec(&session_data).unwrap_or_default();
515        let attrs = Self::session_attrs(session, &ctx);
516        let set_cookie = match server_session::establish(
517            &session.backend,
518            &session.sealer,
519            &payload,
520            ttl,
521            meta,
522            &session.cookie_name,
523            &attrs,
524        )
525        .await
526        {
527            Ok(s) => s,
528            Err(e) => return Err(Self::store_error(ctx, e)),
529        };
530
531        // Do NOT attach identity + succeed on this request: the graph wires
532        // `success` straight to `upstream.in`, and `upstream` replaces
533        // `ctx.response.headers` wholesale, so a Set-Cookie attached here
534        // would never reach the browser. Instead 302-redirect to the
535        // code-stripped URL carrying the cookie; the browser's follow-up
536        // request then hits the session-read fast path above.
537        let target = redirect_target(&ctx, &self.code_query);
538        Self::redirect(ctx, &target, vec![set_cookie])
539    }
540}
541
542/// Rebuilds the current request's path+query with the `code` query
543/// parameter stripped, for the post-establish redirect (so the browser's
544/// follow-up GET doesn't resubmit the one-time code). If the code was read
545/// from the header rather than the query string, there is nothing to strip
546/// and the query comes back unchanged. Mirrors
547/// `authz_casdoor.rs::reconstruct_uri`.
548fn redirect_target(ctx: &Context, code_query: &str) -> String {
549    let mut uri = ctx.request.path.clone();
550    let mut pairs: Vec<String> = Vec::new();
551    for (k, values) in &ctx.request.query_params {
552        if k == code_query {
553            continue;
554        }
555        for v in values {
556            if v.is_empty() {
557                pairs.push(k.clone());
558            } else {
559                pairs.push(format!("{k}={v}"));
560            }
561        }
562    }
563    if !pairs.is_empty() {
564        uri.push('?');
565        uri.push_str(&pairs.join("&"));
566    }
567    uri
568}
569
570fn require_string(
571    config: &HashMap<String, serde_json::Value>,
572    key: &str,
573) -> Result<String, String> {
574    config
575        .get(key)
576        .and_then(|v| v.as_str())
577        .filter(|s| !s.is_empty())
578        .map(String::from)
579        .ok_or_else(|| format!("feishu-auth plugin requires '{}'", key))
580}
581
582/// Reads the session secret from `session_secret` or nested `session.secret`.
583fn session_secret(config: &HashMap<String, serde_json::Value>) -> Option<String> {
584    config
585        .get("session_secret")
586        .and_then(|v| v.as_str())
587        .or_else(|| {
588            config
589                .get("session")
590                .and_then(|s| s.get("secret"))
591                .and_then(|v| v.as_str())
592        })
593        .filter(|s| !s.is_empty())
594        .map(String::from)
595}
596
597/// Reads a string field from nested `session.cookie.<key>`, falling back to the
598/// flat `session_cookie_<key>` form (used by the UI schema).
599fn session_cookie_str(config: &HashMap<String, serde_json::Value>, key: &str) -> Option<String> {
600    config
601        .get("session")
602        .and_then(|s| s.get("cookie"))
603        .and_then(|c| c.get(key))
604        .or_else(|| config.get(&format!("session_cookie_{key}")))
605        .and_then(|v| v.as_str())
606        .filter(|s| !s.is_empty())
607        .map(String::from)
608}
609
610/// Reads a u64 field from nested `session.cookie.<key>`, falling back to the
611/// flat `session_cookie_<key>` form (used by the UI schema).
612fn session_cookie_u64(config: &HashMap<String, serde_json::Value>, key: &str) -> Option<u64> {
613    config
614        .get("session")
615        .and_then(|s| s.get("cookie"))
616        .and_then(|c| c.get(key))
617        .or_else(|| config.get(&format!("session_cookie_{key}")))
618        .and_then(|v| v.as_u64())
619}
620
621fn now_unix() -> u64 {
622    SystemTime::now()
623        .duration_since(UNIX_EPOCH)
624        .map(|d| d.as_secs())
625        .unwrap_or(0)
626}
627
628/// Parses the user access token from Feishu's v2 token response, returning
629/// the token and (when present) its `expires_in` seconds.
630fn parse_access_token(resp: &OutboundResponse) -> Result<(String, Option<u64>), FeishuError> {
631    if resp.status != 200 {
632        return Err(FeishuError::Upstream(format!(
633            "unexpected token response status: {}",
634            resp.status
635        )));
636    }
637    let data: serde_json::Value = serde_json::from_slice(&resp.body)
638        .map_err(|e| FeishuError::Upstream(format!("failed to decode token response: {}", e)))?;
639    // Feishu returns `code: 0` on success for the v2 token endpoint; a non-zero
640    // code (e.g. bad/expired authorization code) is an auth failure.
641    if let Some(code) = data.get("code").and_then(|v| v.as_i64()) {
642        if code != 0 {
643            let msg = data
644                .get("error_description")
645                .and_then(|v| v.as_str())
646                .or_else(|| data.get("msg").and_then(|v| v.as_str()))
647                .unwrap_or("unknown");
648            return Err(FeishuError::Unauthorized(format!(
649                "feishu rejected code (code {}): {}",
650                code, msg
651            )));
652        }
653    }
654    let token = data
655        .get("access_token")
656        .and_then(|v| v.as_str())
657        .map(String::from)
658        .ok_or_else(|| {
659            FeishuError::Unauthorized("token response missing access_token".to_string())
660        })?;
661    let expires_in = data.get("expires_in").and_then(|v| v.as_u64());
662    Ok((token, expires_in))
663}
664
665/// Parses Feishu's userinfo response, returning `data.data` on `code == 0`.
666fn parse_userinfo(resp: &OutboundResponse) -> Result<serde_json::Value, FeishuError> {
667    if resp.status != 200 {
668        return Err(FeishuError::Upstream(format!(
669            "unexpected userinfo response status: {}",
670            resp.status
671        )));
672    }
673    let data: serde_json::Value = serde_json::from_slice(&resp.body)
674        .map_err(|e| FeishuError::Upstream(format!("failed to decode userinfo response: {}", e)))?;
675    let code = data.get("code").and_then(|v| v.as_i64()).unwrap_or(-1);
676    if code != 0 {
677        let msg = data
678            .get("msg")
679            .and_then(|v| v.as_str())
680            .unwrap_or("unknown");
681        return Err(FeishuError::Unauthorized(format!(
682            "feishu userinfo rejected token (code {}): {}",
683            code, msg
684        )));
685    }
686    data.get("data")
687        .cloned()
688        .ok_or_else(|| FeishuError::Upstream("userinfo response missing data".to_string()))
689}
690
691/// Copies the resolved identity into `context.message` and optionally the
692/// `X-Userinfo` request header.
693fn attach_identity(ctx: &mut Context, userinfo: &serde_json::Value, set_header: bool) {
694    ctx.message
695        .insert("feishu_userinfo".to_string(), userinfo.clone());
696    if let Some(uid) = userinfo
697        .get("user_id")
698        .or_else(|| userinfo.get("open_id"))
699        .or_else(|| userinfo.get("union_id"))
700        .and_then(|v| v.as_str())
701    {
702        ctx.message.insert(
703            "user_id".to_string(),
704            serde_json::Value::String(uid.to_string()),
705        );
706    }
707    if set_header {
708        if let Ok(raw) = serde_json::to_vec(userinfo) {
709            ctx.request
710                .headers
711                .insert("x-userinfo".to_string(), vec![BASE64_STANDARD.encode(raw)]);
712        }
713    }
714}
715
716#[async_trait]
717impl Plugin for FeishuAuthPlugin {
718    fn plugin_type(&self) -> &str {
719        "feishu-auth"
720    }
721
722    async fn execute(&self, mut ctx: Context) -> PluginResult {
723        // Never let a client-supplied X-Userinfo bleed through to the upstream.
724        ctx.request.headers.remove("x-userinfo");
725
726        if let Some(session) = &self.session {
727            return self.execute_session(ctx, session).await;
728        }
729
730        let code = match self.extract_code(&ctx) {
731            Some(c) => c,
732            None => return Self::reject(ctx, "Missing Feishu authorization code"),
733        };
734
735        let (access_token, _expires_in) = match self.fetch_access_token(&code).await {
736            Ok(t) => t,
737            Err(FeishuError::Unauthorized(m)) => return Self::reject(ctx, &m),
738            Err(e @ FeishuError::Upstream(_)) => return Self::upstream_error(ctx, e.message()),
739        };
740
741        let userinfo = match self.fetch_userinfo(&access_token).await {
742            Ok(u) => u,
743            Err(FeishuError::Unauthorized(m)) => return Self::reject(ctx, &m),
744            Err(e @ FeishuError::Upstream(_)) => return Self::upstream_error(ctx, e.message()),
745        };
746
747        attach_identity(&mut ctx, &userinfo, self.set_userinfo_header);
748        Ok(PluginOutput::success(ctx))
749    }
750}
751
752#[cfg(test)]
753mod tests {
754    use super::*;
755    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
756
757    fn resp(status: u16, body: serde_json::Value) -> OutboundResponse {
758        OutboundResponse {
759            status,
760            headers: HashMap::new(),
761            body: Bytes::from(serde_json::to_vec(&body).unwrap()),
762        }
763    }
764
765    fn base_ctx() -> Context {
766        Context {
767            request: GatewayRequest {
768                method: "GET".to_string(),
769                path: "/".to_string(),
770                host: "h".to_string(),
771                scheme: "http".to_string(),
772                headers: HashMap::new(),
773                query_params: HashMap::new(),
774                body: Bytes::new(),
775                remote_addr: "1.2.3.4:5".to_string(),
776                protocol: Protocol::Http1,
777            },
778            response: GatewayResponse {
779                status_code: 0,
780                headers: HashMap::new(),
781                body: Bytes::new(),
782                stream: None,
783            },
784            message: HashMap::new(),
785            errors: Vec::new(),
786        }
787    }
788
789    fn full_cfg() -> HashMap<String, serde_json::Value> {
790        [
791            ("app_id", "id"),
792            ("app_secret", "secret"),
793            ("auth_redirect_uri", "https://app/callback"),
794        ]
795        .iter()
796        .map(|(k, v)| (k.to_string(), serde_json::Value::String(v.to_string())))
797        .collect()
798    }
799
800    #[test]
801    fn test_requires_id_secret_redirect() {
802        assert!(FeishuAuthPlugin::from_config(&HashMap::new(), &PluginResources::empty()).is_err());
803        // missing auth_redirect_uri
804        let mut cfg: HashMap<String, serde_json::Value> = HashMap::new();
805        cfg.insert("app_id".to_string(), serde_json::json!("id"));
806        cfg.insert("app_secret".to_string(), serde_json::json!("secret"));
807        assert!(FeishuAuthPlugin::from_config(&cfg, &PluginResources::empty()).is_err());
808        assert!(FeishuAuthPlugin::from_config(&full_cfg(), &PluginResources::empty()).is_ok());
809    }
810
811    #[test]
812    fn test_token_request_body_shape() {
813        let plugin = FeishuAuthPlugin::from_config(&full_cfg(), &PluginResources::empty()).unwrap();
814        let body = plugin.token_request_body("the-code");
815        assert_eq!(body.get("grant_type").unwrap(), "authorization_code");
816        assert_eq!(body.get("client_id").unwrap(), "id");
817        assert_eq!(body.get("client_secret").unwrap(), "secret");
818        assert_eq!(body.get("redirect_uri").unwrap(), "https://app/callback");
819        assert_eq!(body.get("code").unwrap(), "the-code");
820    }
821
822    #[test]
823    fn test_parse_access_token() {
824        let ok = resp(
825            200,
826            serde_json::json!({ "code": 0, "access_token": "tok", "expires_in": 7200 }),
827        );
828        let (token, expires_in) = parse_access_token(&ok).unwrap();
829        assert_eq!(token, "tok");
830        assert_eq!(expires_in, Some(7200));
831
832        // non-zero code → unauthorized
833        let denied = resp(
834            200,
835            serde_json::json!({ "code": 20037, "error_description": "invalid code" }),
836        );
837        assert!(matches!(
838            parse_access_token(&denied),
839            Err(FeishuError::Unauthorized(_))
840        ));
841
842        let bad_status = resp(400, serde_json::json!({}));
843        assert!(matches!(
844            parse_access_token(&bad_status),
845            Err(FeishuError::Upstream(_))
846        ));
847    }
848
849    #[test]
850    fn test_parse_userinfo() {
851        let ok = resp(
852            200,
853            serde_json::json!({ "code": 0, "data": { "user_id": "u1", "name": "Bob" } }),
854        );
855        let data = parse_userinfo(&ok).unwrap();
856        assert_eq!(data.get("user_id").unwrap(), "u1");
857
858        let denied = resp(
859            200,
860            serde_json::json!({ "code": 99991663, "msg": "token invalid" }),
861        );
862        assert!(matches!(
863            parse_userinfo(&denied),
864            Err(FeishuError::Unauthorized(_))
865        ));
866    }
867
868    #[test]
869    fn test_attach_identity() {
870        let mut ctx = base_ctx();
871        let userinfo = serde_json::json!({ "user_id": "u1", "open_id": "ou_x", "name": "Bob" });
872        attach_identity(&mut ctx, &userinfo, true);
873        assert_eq!(ctx.message.get("user_id").unwrap(), "u1");
874        assert!(ctx.request.headers.contains_key("x-userinfo"));
875    }
876
877    #[tokio::test]
878    async fn test_missing_code_rejected_401() {
879        let plugin = FeishuAuthPlugin::from_config(&full_cfg(), &PluginResources::empty()).unwrap();
880        let out = plugin.execute(base_ctx()).await.unwrap();
881        assert_eq!(out.port, Some("denied"));
882        assert_eq!(out.context.response.status_code, 401);
883    }
884
885    #[tokio::test]
886    async fn test_upstream_callout_failure_stays_on_error_port() {
887        // A Feishu callout that fails outright (here: nothing listening on
888        // the port) is a genuine infra failure and must stay a raw `Err`,
889        // unlike the deliberate `Unauthorized`/missing-code denials above.
890        let mut cfg = full_cfg();
891        cfg.insert(
892            "access_token_url".to_string(),
893            serde_json::json!("http://127.0.0.1:1"),
894        );
895        cfg.insert("timeout".to_string(), serde_json::json!(200));
896        let plugin = FeishuAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
897
898        let mut ctx = base_ctx();
899        ctx.request
900            .query_params
901            .insert("code".to_string(), vec!["some-code".to_string()]);
902        let err = plugin.execute(ctx).await.unwrap_err();
903        assert_eq!(err.error.code, "FEISHU_UPSTREAM_ERROR");
904        assert!(err.context.response.status_code >= 500);
905    }
906
907    #[test]
908    fn test_stateless_mode_unchanged() {
909        // No session key → from_config succeeds without redirect_uri, and
910        // the existing behavior (above) is untouched by this file's changes.
911        let plugin = FeishuAuthPlugin::from_config(&full_cfg(), &PluginResources::empty()).unwrap();
912        assert!(plugin.session.is_none());
913    }
914
915    #[test]
916    fn test_session_mode_requires_redirect_uri() {
917        // session.secret set but no redirect_uri → config error naming it.
918        let mut cfg = full_cfg();
919        cfg.insert(
920            "session".to_string(),
921            serde_json::json!({ "secret": "s3cr3t" }),
922        );
923        let err = FeishuAuthPlugin::from_config(&cfg, &PluginResources::empty())
924            .err()
925            .unwrap();
926        assert!(err.contains("redirect_uri"), "{err}");
927    }
928
929    #[tokio::test]
930    async fn test_session_mode_no_code_redirects() {
931        let mut cfg = full_cfg();
932        cfg.insert(
933            "redirect_uri".to_string(),
934            serde_json::json!("https://login.example.com/start"),
935        );
936        cfg.insert(
937            "session".to_string(),
938            serde_json::json!({"secret": "s3cr3t"}),
939        );
940        let plugin = FeishuAuthPlugin::from_config(&cfg, &PluginResources::empty()).unwrap();
941        let out = plugin.execute(base_ctx()).await.unwrap();
942        assert_eq!(out.port, Some("redirect"));
943        assert_eq!(out.context.response.status_code, 302);
944        assert_eq!(
945            out.context.response.headers["location"],
946            vec!["https://login.example.com/start".to_string()]
947        );
948    }
949
950    #[tokio::test]
951    async fn test_session_mode_valid_cookie_skips_callout() {
952        // Cookie mode: hand-seal the session payload, present it, assert
953        // success + identity attached WITHOUT any HTTP callout (endpoints
954        // point at 127.0.0.1:1 — reaching them would error).
955        let mut config = full_cfg();
956        config.insert(
957            "access_token_url".to_string(),
958            serde_json::json!("http://127.0.0.1:1"),
959        );
960        config.insert(
961            "userinfo_url".to_string(),
962            serde_json::json!("http://127.0.0.1:1"),
963        );
964        config.insert(
965            "redirect_uri".to_string(),
966            serde_json::json!("https://login.example.com/start"),
967        );
968        config.insert("timeout".to_string(), serde_json::json!(200));
969        config.insert(
970            "session".to_string(),
971            serde_json::json!({ "secret": "s3cr3t" }),
972        );
973        let plugin = FeishuAuthPlugin::from_config(&config, &PluginResources::empty()).unwrap();
974
975        let sealer = CookieSealer::new("s3cr3t");
976        let session_data = FeishuSessionData {
977            userinfo: serde_json::json!({ "user_id": "u1" }),
978            access_token: Some("cached-token".to_string()),
979            access_token_expires_at: Some(1_000_000),
980        };
981        let payload = serde_json::to_vec(&session_data).unwrap();
982        let sealed = sealer.seal(&payload, Duration::from_secs(86_400));
983
984        let mut ctx = base_ctx();
985        ctx.request.headers.insert(
986            "cookie".to_string(),
987            vec![format!("feishu_session={}", sealed)],
988        );
989
990        let out = plugin.execute(ctx).await.unwrap();
991        assert!(out.port.is_none());
992        assert_eq!(out.context.message.get("user_id").unwrap(), "u1");
993    }
994
995    /// One-shot mock HTTP server: accepts a single connection and replies with
996    /// the given JSON body.
997    async fn spawn_json_server(body: serde_json::Value) -> u16 {
998        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
999        let port = listener.local_addr().unwrap().port();
1000        tokio::spawn(async move {
1001            if let Ok((mut stream, _)) = listener.accept().await {
1002                use tokio::io::{AsyncReadExt, AsyncWriteExt};
1003                let mut buf = [0u8; 4096];
1004                let _ = stream.read(&mut buf).await;
1005                let body = body.to_string();
1006                let _ = stream
1007                    .write_all(
1008                        format!(
1009                            "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
1010                            body.len(),
1011                            body
1012                        )
1013                        .as_bytes(),
1014                    )
1015                    .await;
1016                let _ = stream.shutdown().await;
1017            }
1018        });
1019        port
1020    }
1021
1022    /// The critical regression test for the Set-Cookie-swallowed-by-upstream
1023    /// bug: on a successful code exchange in session mode, the node must NOT
1024    /// exit on `success` (that edge feeds `upstream.in`, which replaces
1025    /// `ctx.response.headers` wholesale, dropping the Set-Cookie and causing
1026    /// an infinite login loop). It must instead 302-redirect to the
1027    /// code-stripped URL carrying the session cookie, and a follow-up
1028    /// request presenting that cookie must take the session-read fast path.
1029    #[tokio::test]
1030    async fn test_session_mode_code_exchange_redirects_with_cookie() {
1031        let token_port = spawn_json_server(serde_json::json!({
1032            "code": 0,
1033            "access_token": "user-token",
1034            "expires_in": 7200
1035        }))
1036        .await;
1037        let userinfo_port = spawn_json_server(serde_json::json!({
1038            "code": 0,
1039            "data": { "user_id": "u1", "name": "Bob" }
1040        }))
1041        .await;
1042
1043        let mut config = full_cfg();
1044        let token_url = format!("http://127.0.0.1:{}", token_port);
1045        let userinfo_url = format!("http://127.0.0.1:{}", userinfo_port);
1046        config.insert("access_token_url".to_string(), serde_json::json!(token_url));
1047        config.insert("userinfo_url".to_string(), serde_json::json!(userinfo_url));
1048        config.insert(
1049            "redirect_uri".to_string(),
1050            serde_json::json!("https://login.example.com/start"),
1051        );
1052        config.insert("timeout".to_string(), serde_json::json!(2000));
1053        config.insert(
1054            "session".to_string(),
1055            serde_json::json!({ "secret": "s3cr3t" }),
1056        );
1057        let plugin = FeishuAuthPlugin::from_config(&config, &PluginResources::empty()).unwrap();
1058
1059        let mut ctx = base_ctx();
1060        ctx.request.path = "/callback".to_string();
1061        ctx.request
1062            .query_params
1063            .insert("code".to_string(), vec!["one-time-code".to_string()]);
1064        ctx.request
1065            .query_params
1066            .insert("foo".to_string(), vec!["bar".to_string()]);
1067
1068        let out = plugin.execute(ctx).await.unwrap();
1069        assert_eq!(out.port, Some("redirect"));
1070        assert_eq!(out.context.response.status_code, 302);
1071        assert_eq!(
1072            out.context.response.headers["location"],
1073            vec!["/callback?foo=bar".to_string()]
1074        );
1075        // No identity should be attached on this response — it's a bare
1076        // redirect, not a success.
1077        assert!(!out.context.message.contains_key("user_id"));
1078        let set_cookie = out.context.response.headers["set-cookie"][0].clone();
1079        assert!(set_cookie.starts_with("feishu_session="), "{set_cookie}");
1080
1081        // Follow-up request presenting the cookie the redirect just set must
1082        // take the fast path: success, identity attached, no callout.
1083        let cookie_value = set_cookie.split(';').next().unwrap().to_string();
1084        let mut ctx2 = base_ctx();
1085        ctx2.request
1086            .headers
1087            .insert("cookie".to_string(), vec![cookie_value]);
1088        let out2 = plugin.execute(ctx2).await.unwrap();
1089        assert!(out2.port.is_none());
1090        assert_eq!(out2.context.message.get("user_id").unwrap(), "u1");
1091    }
1092
1093    #[cfg(feature = "redis-store")]
1094    fn resources_with_fake_store() -> (Arc<PluginResources>, Arc<crate::sessions::FakeSessionStore>)
1095    {
1096        let fake = Arc::new(crate::sessions::FakeSessionStore::default());
1097        let resources = PluginResources::empty();
1098        resources.stores.store(Arc::new(
1099            crate::stores::StoreRegistry::with_fake_session_store("s1", fake.clone()),
1100        ));
1101        (resources, fake)
1102    }
1103
1104    /// In redis mode a valid session cookie authenticates from the store
1105    /// without any Feishu callout, and a store outage is a 503 on the
1106    /// error port — never a silent re-login.
1107    #[cfg(feature = "redis-store")]
1108    #[tokio::test]
1109    async fn test_redis_session_read_and_store_outage_503() {
1110        use crate::sessions::SessionStore as _;
1111
1112        let (resources, fake) = resources_with_fake_store();
1113        let mut config = full_cfg();
1114        config.insert(
1115            "access_token_url".to_string(),
1116            serde_json::json!("http://127.0.0.1:1"),
1117        );
1118        config.insert(
1119            "userinfo_url".to_string(),
1120            serde_json::json!("http://127.0.0.1:1"),
1121        );
1122        config.insert(
1123            "redirect_uri".to_string(),
1124            serde_json::json!("https://login.example.com/start"),
1125        );
1126        config.insert("timeout".to_string(), serde_json::json!(200));
1127        config.insert(
1128            "session".to_string(),
1129            serde_json::json!({ "secret": "s3cr3t", "storage": "redis", "store": "s1" }),
1130        );
1131        let plugin = FeishuAuthPlugin::from_config(&config, &resources).unwrap();
1132
1133        // Hand-put sealed session data under an id, the way a successful
1134        // callback would have established it.
1135        let sealer = CookieSealer::new("s3cr3t");
1136        let session_data = FeishuSessionData {
1137            userinfo: serde_json::json!({ "user_id": "u1" }),
1138            access_token: None,
1139            access_token_expires_at: None,
1140        };
1141        let payload = serde_json::to_vec(&session_data).unwrap();
1142        let sealed = sealer.seal(&payload, Duration::from_secs(86_400));
1143        let id = crate::sessions::SessionId::random();
1144        let meta = crate::sessions::SessionMeta {
1145            id: String::new(),
1146            subject: "u1".to_string(),
1147            plugin: "feishu-auth".to_string(),
1148            policy: String::new(),
1149            route: String::new(),
1150            created_at: 0,
1151            expires_at: 0,
1152        };
1153        fake.put(&id, sealed.as_bytes(), Duration::from_secs(86_400), &meta)
1154            .await
1155            .unwrap();
1156
1157        let mut ctx = base_ctx();
1158        ctx.request.headers.insert(
1159            "cookie".to_string(),
1160            vec![format!("feishu_session={}", id.as_str())],
1161        );
1162        let out = plugin.execute(ctx).await.unwrap();
1163        assert!(out.port.is_none());
1164        assert_eq!(out.context.message.get("user_id").unwrap(), "u1");
1165
1166        // Outage: same request, failing store.
1167        fake.fail.store(true, std::sync::atomic::Ordering::Relaxed);
1168        let mut ctx = base_ctx();
1169        ctx.request.headers.insert(
1170            "cookie".to_string(),
1171            vec![format!("feishu_session={}", id.as_str())],
1172        );
1173        let err = plugin.execute(ctx).await.unwrap_err();
1174        assert_eq!(err.error.code, "SESSION_STORE_ERROR");
1175        assert_eq!(err.context.response.status_code, 503);
1176    }
1177}