mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
861fedcb33 |
@@ -27,7 +27,7 @@
|
|||||||
{
|
{
|
||||||
"name": "feishu_verification_token",
|
"name": "feishu_verification_token",
|
||||||
"prompt": "Enter your Feishu/Lark Verification Token (from Event Subscription webhook settings)",
|
"prompt": "Enter your Feishu/Lark Verification Token (from Event Subscription webhook settings)",
|
||||||
"optional": true
|
"optional": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"setup_url": "https://open.feishu.cn/app"
|
"setup_url": "https://open.feishu.cn/app"
|
||||||
@@ -70,6 +70,7 @@
|
|||||||
"config": {
|
"config": {
|
||||||
"app_id": null,
|
"app_id": null,
|
||||||
"app_secret": null,
|
"app_secret": null,
|
||||||
|
"verification_token": null,
|
||||||
"api_base": "https://open.feishu.cn",
|
"api_base": "https://open.feishu.cn",
|
||||||
"owner_id": null,
|
"owner_id": null,
|
||||||
"dm_policy": "pairing",
|
"dm_policy": "pairing",
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
//! - App credentials (app_id, app_secret) are injected by the host into
|
//! - App credentials (app_id, app_secret) are injected by the host into
|
||||||
//! the config JSON during startup for token exchange
|
//! the config JSON during startup for token exchange
|
||||||
//! - Bearer token for API calls is obtained via token exchange and cached
|
//! - Bearer token for API calls is obtained via token exchange and cached
|
||||||
//! - Verification token validated by host for webhook requests
|
//! - Webhook requests must be authenticated by the host or by a matching
|
||||||
|
//! Feishu verification token in the request body
|
||||||
|
|
||||||
// Generate bindings from the WIT file
|
// Generate bindings from the WIT file
|
||||||
wit_bindgen::generate!({
|
wit_bindgen::generate!({
|
||||||
@@ -50,6 +51,7 @@ const ALLOW_FROM_PATH: &str = "allow_from";
|
|||||||
const API_BASE_PATH: &str = "api_base";
|
const API_BASE_PATH: &str = "api_base";
|
||||||
const APP_ID_PATH: &str = "app_id";
|
const APP_ID_PATH: &str = "app_id";
|
||||||
const APP_SECRET_PATH: &str = "app_secret";
|
const APP_SECRET_PATH: &str = "app_secret";
|
||||||
|
const VERIFICATION_TOKEN_PATH: &str = "verification_token";
|
||||||
const TOKEN_PATH: &str = "tenant_access_token";
|
const TOKEN_PATH: &str = "tenant_access_token";
|
||||||
const TOKEN_EXPIRY_PATH: &str = "token_expiry";
|
const TOKEN_EXPIRY_PATH: &str = "token_expiry";
|
||||||
|
|
||||||
@@ -251,6 +253,9 @@ struct FeishuConfig {
|
|||||||
/// Feishu App Secret (for token exchange).
|
/// Feishu App Secret (for token exchange).
|
||||||
app_secret: Option<String>,
|
app_secret: Option<String>,
|
||||||
|
|
||||||
|
/// Feishu Event Subscription verification token.
|
||||||
|
verification_token: Option<String>,
|
||||||
|
|
||||||
/// API base URL. Defaults to "https://open.feishu.cn" (use
|
/// API base URL. Defaults to "https://open.feishu.cn" (use
|
||||||
/// "https://open.larksuite.com" for Lark international).
|
/// "https://open.larksuite.com" for Lark international).
|
||||||
#[serde(default = "default_api_base")]
|
#[serde(default = "default_api_base")]
|
||||||
@@ -300,6 +305,11 @@ impl Guest for FeishuChannel {
|
|||||||
if let Some(ref app_secret) = config.app_secret {
|
if let Some(ref app_secret) = config.app_secret {
|
||||||
let _ = channel_host::workspace_write(APP_SECRET_PATH, app_secret);
|
let _ = channel_host::workspace_write(APP_SECRET_PATH, app_secret);
|
||||||
}
|
}
|
||||||
|
if let Some(ref verification_token) = config.verification_token {
|
||||||
|
let _ = channel_host::workspace_write(VERIFICATION_TOKEN_PATH, verification_token);
|
||||||
|
} else {
|
||||||
|
let _ = channel_host::workspace_write(VERIFICATION_TOKEN_PATH, "");
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(owner_id) = &config.owner_id {
|
if let Some(owner_id) = &config.owner_id {
|
||||||
let _ = channel_host::workspace_write(OWNER_ID_PATH, owner_id);
|
let _ = channel_host::workspace_write(OWNER_ID_PATH, owner_id);
|
||||||
@@ -376,6 +386,23 @@ impl Guest for FeishuChannel {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let configured_token =
|
||||||
|
channel_host::workspace_read(VERIFICATION_TOKEN_PATH).filter(|token| !token.is_empty());
|
||||||
|
if !is_authenticated_webhook(
|
||||||
|
req.secret_validated,
|
||||||
|
configured_token.as_deref(),
|
||||||
|
event.token.as_deref(),
|
||||||
|
) {
|
||||||
|
channel_host::log(
|
||||||
|
channel_host::LogLevel::Warn,
|
||||||
|
"Rejecting unauthenticated Feishu webhook request",
|
||||||
|
);
|
||||||
|
return json_response(
|
||||||
|
401,
|
||||||
|
serde_json::json!({"error": "Webhook authentication failed"}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Handle URL verification challenge (initial webhook setup).
|
// Handle URL verification challenge (initial webhook setup).
|
||||||
if event.event_type.as_deref() == Some("url_verification") {
|
if event.event_type.as_deref() == Some("url_verification") {
|
||||||
if let Some(challenge) = &event.challenge {
|
if let Some(challenge) = &event.challenge {
|
||||||
@@ -839,6 +866,21 @@ fn json_response(status: u16, body: serde_json::Value) -> OutgoingHttpResponse {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_authenticated_webhook(
|
||||||
|
secret_validated: bool,
|
||||||
|
configured_token: Option<&str>,
|
||||||
|
request_token: Option<&str>,
|
||||||
|
) -> bool {
|
||||||
|
if secret_validated {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
match (configured_token, request_token) {
|
||||||
|
(Some(expected), Some(provided)) => expected == provided,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -862,7 +904,10 @@ mod tests {
|
|||||||
fn parse_token_response_rejects_missing_token() {
|
fn parse_token_response_rejects_missing_token() {
|
||||||
let json = r#"{"code": 0, "msg": "ok", "expire": 7200}"#;
|
let json = r#"{"code": 0, "msg": "ok", "expire": 7200}"#;
|
||||||
let result: Result<TenantAccessTokenResponse, _> = serde_json::from_str(json);
|
let result: Result<TenantAccessTokenResponse, _> = serde_json::from_str(json);
|
||||||
assert!(result.is_err(), "should fail when tenant_access_token is missing");
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"should fail when tenant_access_token is missing"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -894,4 +939,32 @@ mod tests {
|
|||||||
assert_eq!(resp.code, 10003);
|
assert_eq!(resp.code, 10003);
|
||||||
assert!(resp.tenant_access_token.is_empty());
|
assert!(resp.tenant_access_token.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn webhook_auth_requires_host_auth_or_matching_verification_token() {
|
||||||
|
assert!(
|
||||||
|
!is_authenticated_webhook(false, None, Some("token")),
|
||||||
|
"requests without any configured verification mechanism must be rejected"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_authenticated_webhook(false, Some("expected"), None),
|
||||||
|
"requests missing the Feishu token must be rejected when host auth did not pass"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!is_authenticated_webhook(false, Some("expected"), Some("wrong")),
|
||||||
|
"requests with the wrong Feishu token must be rejected"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
is_authenticated_webhook(false, Some("expected"), Some("expected")),
|
||||||
|
"matching Feishu verification token should authenticate the request"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
is_authenticated_webhook(true, None, None),
|
||||||
|
"host-authenticated requests should still be accepted"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
is_authenticated_webhook(true, Some("expected"), Some("wrong")),
|
||||||
|
"host authentication should take precedence over body token checks"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,13 +139,14 @@ async fn register_channel(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let secret_header = loaded.webhook_secret_header().map(|s| s.to_string());
|
let secret_header = loaded.webhook_secret_header().map(|s| s.to_string());
|
||||||
|
let host_webhook_secret = host_managed_webhook_secret(&channel_name, webhook_secret.clone());
|
||||||
|
|
||||||
let webhook_path = format!("/webhook/{}", channel_name);
|
let webhook_path = format!("/webhook/{}", channel_name);
|
||||||
let endpoints = vec![RegisteredEndpoint {
|
let endpoints = vec![RegisteredEndpoint {
|
||||||
channel_name: channel_name.clone(),
|
channel_name: channel_name.clone(),
|
||||||
path: webhook_path,
|
path: webhook_path,
|
||||||
methods: vec!["POST".to_string()],
|
methods: vec!["POST".to_string()],
|
||||||
require_secret: webhook_secret.is_some(),
|
require_secret: host_webhook_secret.is_some(),
|
||||||
}];
|
}];
|
||||||
|
|
||||||
let channel_arc = Arc::new(loaded.channel.with_owner_actor_id(owner_actor_id.clone()));
|
let channel_arc = Arc::new(loaded.channel.with_owner_actor_id(owner_actor_id.clone()));
|
||||||
@@ -205,7 +206,7 @@ async fn register_channel(
|
|||||||
|
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
channel = %channel_name,
|
channel = %channel_name,
|
||||||
has_webhook_secret = webhook_secret.is_some(),
|
has_webhook_secret = host_webhook_secret.is_some(),
|
||||||
secret_header = ?secret_header,
|
secret_header = ?secret_header,
|
||||||
"Registering channel with router"
|
"Registering channel with router"
|
||||||
);
|
);
|
||||||
@@ -214,7 +215,7 @@ async fn register_channel(
|
|||||||
.register(
|
.register(
|
||||||
Arc::clone(&channel_arc),
|
Arc::clone(&channel_arc),
|
||||||
endpoints,
|
endpoints,
|
||||||
webhook_secret.clone(),
|
host_webhook_secret.clone(),
|
||||||
secret_header,
|
secret_header,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -384,6 +385,17 @@ pub async fn inject_channel_credentials(
|
|||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn host_managed_webhook_secret(
|
||||||
|
channel_name: &str,
|
||||||
|
webhook_secret: Option<String>,
|
||||||
|
) -> Option<String> {
|
||||||
|
if channel_name == "feishu" {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
webhook_secret
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Inject channel-specific secrets into the config JSON.
|
/// Inject channel-specific secrets into the config JSON.
|
||||||
///
|
///
|
||||||
/// Some channels (e.g., Feishu) need raw credential values in their config
|
/// Some channels (e.g., Feishu) need raw credential values in their config
|
||||||
@@ -392,8 +404,9 @@ pub async fn inject_channel_credentials(
|
|||||||
/// placeholders in URLs and headers, so this function fills config fields
|
/// placeholders in URLs and headers, so this function fills config fields
|
||||||
/// that map to secret names.
|
/// that map to secret names.
|
||||||
///
|
///
|
||||||
/// Mapping: for a channel named "feishu", secrets `feishu_app_id` and
|
/// Mapping: for a channel named "feishu", secrets `feishu_app_id`,
|
||||||
/// `feishu_app_secret` are injected as config keys `app_id` and `app_secret`.
|
/// `feishu_app_secret`, and `feishu_verification_token` are injected as config
|
||||||
|
/// keys `app_id`, `app_secret`, and `verification_token`.
|
||||||
async fn inject_channel_secrets_into_config(
|
async fn inject_channel_secrets_into_config(
|
||||||
channel_name: &str,
|
channel_name: &str,
|
||||||
secrets_store: &Option<Arc<dyn SecretsStore + Send + Sync>>,
|
secrets_store: &Option<Arc<dyn SecretsStore + Send + Sync>>,
|
||||||
@@ -404,6 +417,7 @@ async fn inject_channel_secrets_into_config(
|
|||||||
"feishu" => &[
|
"feishu" => &[
|
||||||
("app_id", "feishu_app_id"),
|
("app_id", "feishu_app_id"),
|
||||||
("app_secret", "feishu_app_secret"),
|
("app_secret", "feishu_app_secret"),
|
||||||
|
("verification_token", "feishu_verification_token"),
|
||||||
],
|
],
|
||||||
_ => return,
|
_ => return,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user