fix(feishu): parse flat token response from tenant_access_token API (#1419)

* fix(feishu): parse flat token response from tenant_access_token API

  The Feishu /auth/v3/tenant_access_token/internal endpoint returns a flat
  JSON response with tenant_access_token and expire at the top level, not
  nested under a "data" field. The previous code used FeishuApiResponse<T>
  which expects a "data" wrapper, causing all token exchanges to fail with
  "Token response missing data" despite receiving a valid HTTP 200 response.

  - Replace TenantAccessTokenData with TenantAccessTokenResponse that includes
  code/msg/tenant_access_token/expire at the top level
  - Deserialize token response directly instead of via FeishuApiResponse<T> wrapper
  - Add empty-token guard to catch malformed responses
  - No changes to FeishuApiResponse<T> or other API call paths

  Fixes #1391

* fix(feishu): address review feedback on token response parsing

- Remove #[serde(default)] from tenant_access_token and expire fields
  so deserialization fails explicitly when critical fields are missing
- Add expire > 0 validation guard to prevent refresh loops or overflow
- Use saturating_add/saturating_mul for expiry calculation
- Add 5 regression tests for TenantAccessTokenResponse deserialization

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: reidliu <[email protected]>
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-03-19 10:33:58 -07:00
committed by GitHub
co-authored by reidliu Claude Opus 4.6
parent 71f9012de3
commit 71f41dd123
+87 -13
View File
@@ -206,9 +206,17 @@ struct FeishuApiResponse<T> {
data: Option<T>, data: Option<T>,
} }
/// Tenant access token response. /// Tenant access token response (flat format).
#[derive(Debug, Default, Deserialize)] ///
struct TenantAccessTokenData { /// Unlike most Feishu APIs that nest results under `data`, the
/// `/auth/v3/tenant_access_token/internal` endpoint returns `code`, `msg`,
/// `tenant_access_token`, and `expire` at the top level.
#[derive(Debug, Deserialize)]
struct TenantAccessTokenResponse {
#[serde(default)]
code: i32,
#[serde(default)]
msg: String,
tenant_access_token: String, tenant_access_token: String,
expire: i64, expire: i64,
} }
@@ -770,9 +778,8 @@ fn obtain_tenant_token(api_base: &str) -> Result<String, String> {
)); ));
} }
let token_resp: FeishuApiResponse<TenantAccessTokenData> = let token_resp: TenantAccessTokenResponse = serde_json::from_slice(&response.body)
serde_json::from_slice(&response.body) .map_err(|e| format!("Failed to parse token response: {}", e))?;
.map_err(|e| format!("Failed to parse token response: {}", e))?;
if token_resp.code != 0 { if token_resp.code != 0 {
return Err(format!( return Err(format!(
@@ -781,23 +788,33 @@ fn obtain_tenant_token(api_base: &str) -> Result<String, String> {
)); ));
} }
let data = token_resp if token_resp.tenant_access_token.is_empty() {
.data return Err("Token response missing tenant_access_token".to_string());
.ok_or_else(|| "Token response missing data".to_string())?; }
if token_resp.expire <= 0 {
return Err(format!(
"Token response has invalid expire value: {}",
token_resp.expire
));
}
// Cache the token with expiry. // Cache the token with expiry.
let now = channel_host::now_millis(); let now = channel_host::now_millis();
let expiry = now + (data.expire as u64) * 1000; let expiry = now.saturating_add((token_resp.expire as u64).saturating_mul(1000));
let _ = channel_host::workspace_write(TOKEN_PATH, &data.tenant_access_token); let _ = channel_host::workspace_write(TOKEN_PATH, &token_resp.tenant_access_token);
let _ = channel_host::workspace_write(TOKEN_EXPIRY_PATH, &expiry.to_string()); let _ = channel_host::workspace_write(TOKEN_EXPIRY_PATH, &expiry.to_string());
channel_host::log( channel_host::log(
channel_host::LogLevel::Debug, channel_host::LogLevel::Debug,
&format!("Tenant access token refreshed, expires in {}s", data.expire), &format!(
"Tenant access token refreshed, expires in {}s",
token_resp.expire
),
); );
Ok(data.tenant_access_token) Ok(token_resp.tenant_access_token)
} }
Err(e) => Err(format!("Token exchange request failed: {}", e)), Err(e) => Err(format!("Token exchange request failed: {}", e)),
} }
@@ -819,3 +836,60 @@ fn json_response(status: u16, body: serde_json::Value) -> OutgoingHttpResponse {
body: body_bytes, body: body_bytes,
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_flat_token_response() {
let json = r#"{
"code": 0,
"msg": "ok",
"tenant_access_token": "t-abc123",
"expire": 7200
}"#;
let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.code, 0);
assert_eq!(resp.msg, "ok");
assert_eq!(resp.tenant_access_token, "t-abc123");
assert_eq!(resp.expire, 7200);
}
#[test]
fn parse_token_response_rejects_missing_token() {
let json = r#"{"code": 0, "msg": "ok", "expire": 7200}"#;
let result: Result<TenantAccessTokenResponse, _> = serde_json::from_str(json);
assert!(result.is_err(), "should fail when tenant_access_token is missing");
}
#[test]
fn parse_token_response_rejects_missing_expire() {
let json = r#"{"code": 0, "msg": "ok", "tenant_access_token": "t-abc"}"#;
let result: Result<TenantAccessTokenResponse, _> = serde_json::from_str(json);
assert!(result.is_err(), "should fail when expire is missing");
}
#[test]
fn parse_token_response_defaults_code_and_msg() {
let json = r#"{"tenant_access_token": "t-abc", "expire": 3600}"#;
let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.code, 0);
assert_eq!(resp.msg, "");
assert_eq!(resp.tenant_access_token, "t-abc");
assert_eq!(resp.expire, 3600);
}
#[test]
fn parse_token_error_response() {
let json = r#"{
"code": 10003,
"msg": "invalid app_id",
"tenant_access_token": "",
"expire": 0
}"#;
let resp: TenantAccessTokenResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.code, 10003);
assert!(resp.tenant_access_token.is_empty());
}
}