1use 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#[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
53pub 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 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 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 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 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
268fn 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 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
299fn 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
325fn 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 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 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}