fix(composio): collect WABA ID before WhatsApp Business OAuth flow (#1550)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
YellowSnnowmann
2026-05-12 19:51:54 -07:00
committed by GitHub
co-authored by Claude Sonnet 4.6 Steven Enamakel
parent 3bfe53aaa9
commit 706edf5f44
8 changed files with 167 additions and 22 deletions
@@ -13,7 +13,7 @@
* keep the Skills page badge in sync too, so the card reflects the new
* state as soon as the modal closes.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import { type ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import {
@@ -73,6 +73,9 @@ export default function ComposioConnectModal({
);
const [error, setError] = useState<string | null>(null);
const [connectUrl, setConnectUrl] = useState<string | null>(null);
// WhatsApp Business requires a WABA ID before the OAuth flow can start.
const [wabaId, setWabaId] = useState('');
const needsWabaId = toolkit.slug === 'whatsapp';
const [activeConnection, setActiveConnection] = useState<ComposioConnection | undefined>(
connection
);
@@ -188,11 +191,16 @@ export default function ComposioConnectModal({
}, []);
const handleConnect = useCallback(async () => {
if (needsWabaId && !wabaId.trim()) {
setError('Please enter your WhatsApp Business Account ID (WABA ID) to continue.');
return;
}
setPhase('authorizing');
setError(null);
setConnectUrl(null);
try {
const resp = await authorize(toolkit.slug);
const extraParams = needsWabaId ? { waba_id: wabaId.trim() } : undefined;
const resp = await authorize(toolkit.slug, extraParams);
setConnectUrl(resp.connectUrl);
await openUrl(resp.connectUrl);
setPhase('waiting');
@@ -202,7 +210,7 @@ export default function ComposioConnectModal({
setPhase('error');
setError(`Authorization failed: ${msg}`);
}
}, [startPolling, toolkit.slug]);
}, [needsWabaId, startPolling, toolkit.slug, wabaId]);
// Fetch the stored scope pref whenever the modal lands in the
// 'connected' phase. Re-fetching each time we transition (rather
@@ -360,6 +368,35 @@ export default function ComposioConnectModal({
admin toggles.
</p>
</div>
{needsWabaId && (
<div className="space-y-1.5">
<label
htmlFor="waba-id-input"
className="block text-xs font-medium text-stone-700">
WhatsApp Business Account ID (WABA ID)
<span className="ml-1 text-coral-500">*</span>
</label>
<input
id="waba-id-input"
type="text"
value={wabaId}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setWabaId(e.target.value);
if (error) setError(null);
}}
placeholder="e.g. 123456789012345"
className="w-full rounded-xl border border-stone-200 bg-white px-3 py-2 text-sm text-stone-900 placeholder:text-stone-400 focus:border-primary-400 focus:outline-none focus:ring-2 focus:ring-primary-100"
/>
<p className="text-[11px] leading-relaxed text-stone-400">
Find it via <span className="font-mono">GET /me/businesses</span> then{' '}
<span className="font-mono">
GET /&#123;business_id&#125;/owned_whatsapp_business_accounts
</span>{' '}
using your Meta access token.
</p>
</div>
)}
{error && phase === 'idle' && <p className="text-[11px] text-coral-600">{error}</p>}
<button
type="button"
onClick={() => void handleConnect()}
+8 -2
View File
@@ -73,11 +73,17 @@ export async function listTools(toolkits?: string[]): Promise<ComposioToolsRespo
* Begin an OAuth handoff for `toolkit`. The returned `connectUrl`
* must be opened in a browser for the user to complete the flow.
* The core publishes a `ComposioConnectionCreated` event on success.
*
* `extraParams` is merged into the backend request body. Required for
* toolkits that need additional fields (e.g. `whatsapp` needs `waba_id`).
*/
export async function authorize(toolkit: string): Promise<ComposioAuthorizeResponse> {
export async function authorize(
toolkit: string,
extraParams?: Record<string, string>
): Promise<ComposioAuthorizeResponse> {
const raw = await callCoreRpc<unknown>({
method: 'openhuman.composio_authorize',
params: { toolkit },
params: extraParams ? { toolkit, extra_params: extraParams } : { toolkit },
});
return unwrapCliEnvelope<ComposioAuthorizeResponse>(raw);
}
+29 -3
View File
@@ -65,13 +65,39 @@ impl ComposioClient {
/// `POST /agent-integrations/composio/authorize` — begin an OAuth
/// handoff for `toolkit` and return the hosted `connectUrl` the user
/// must open in a browser.
pub async fn authorize(&self, toolkit: &str) -> Result<ComposioAuthorizeResponse> {
///
/// `extra_params` is an optional JSON object whose key/value pairs are
/// merged into the request body. Some toolkits (e.g. `whatsapp`) require
/// additional fields (e.g. `waba_id`) that Composio will reject the
/// authorization without.
pub async fn authorize(
&self,
toolkit: &str,
extra_params: Option<serde_json::Value>,
) -> Result<ComposioAuthorizeResponse> {
let toolkit = toolkit.trim();
if toolkit.is_empty() {
anyhow::bail!("composio.authorize: toolkit must not be empty");
}
tracing::debug!(toolkit = %toolkit, "[composio] authorize");
let body = json!({ "toolkit": toolkit });
tracing::debug!(toolkit = %toolkit, has_extra_params = extra_params.is_some(), "[composio] authorize");
let mut body = serde_json::json!({ "toolkit": toolkit });
if let Some(extra) = extra_params {
const RESERVED: &[&str] = &["toolkit", "toolkit_version", "auth", "client_id"];
let extra_obj = extra.as_object().ok_or_else(|| {
anyhow::anyhow!("composio.authorize: extra_params must be a JSON object")
})?;
let obj = body.as_object_mut().ok_or_else(|| {
anyhow::anyhow!("composio.authorize: internal payload must be an object")
})?;
for (k, v) in extra_obj {
if RESERVED.contains(&k.as_str()) {
anyhow::bail!(
"composio.authorize: extra_params cannot override reserved key '{k}'"
);
}
obj.insert(k.clone(), v.clone());
}
}
self.inner
.post::<ComposioAuthorizeResponse>("/agent-integrations/composio/authorize", &body)
.await
+64 -2
View File
@@ -41,13 +41,50 @@ async fn authorize_rejects_empty_toolkit() {
"test".into(),
));
let client = ComposioClient::new(inner);
let err = client.authorize(" ").await.unwrap_err();
let err = client.authorize(" ", None).await.unwrap_err();
assert!(
err.to_string().contains("toolkit must not be empty"),
"unexpected error: {err}"
);
}
/// `authorize()` must reject a non-object `extra_params` before making any HTTP call.
#[tokio::test]
async fn authorize_rejects_non_object_extra_params() {
let inner = Arc::new(crate::openhuman::integrations::IntegrationClient::new(
"http://127.0.0.1:0".into(),
"test".into(),
));
let client = ComposioClient::new(inner);
let err = client
.authorize("whatsapp", Some(serde_json::json!("waba-123")))
.await
.unwrap_err();
assert!(
err.to_string()
.contains("extra_params must be a JSON object"),
"unexpected error: {err}"
);
}
/// `authorize()` must reject an `extra_params` object that tries to override a reserved key.
#[tokio::test]
async fn authorize_rejects_reserved_key_override() {
let inner = Arc::new(crate::openhuman::integrations::IntegrationClient::new(
"http://127.0.0.1:0".into(),
"test".into(),
));
let client = ComposioClient::new(inner);
let err = client
.authorize("whatsapp", Some(serde_json::json!({ "toolkit": "gmail" })))
.await
.unwrap_err();
assert!(
err.to_string().contains("cannot override reserved key"),
"unexpected error: {err}"
);
}
/// `delete_connection()` likewise must reject empty connection ids.
#[tokio::test]
async fn delete_connection_rejects_empty_id() {
@@ -191,11 +228,36 @@ async fn authorize_posts_toolkit_and_returns_connect_url() {
);
let base = start_mock_backend(app).await;
let client = build_client_for(base);
let resp = client.authorize("gmail").await.unwrap();
let resp = client.authorize("gmail", None).await.unwrap();
assert!(resp.connect_url.contains("gmail"));
assert_eq!(resp.connection_id, "conn-abc");
}
#[tokio::test]
async fn authorize_forwards_extra_params_and_returns_connect_url() {
let app = Router::new().route(
"/agent-integrations/composio/authorize",
post(|Json(body): Json<Value>| async move {
// Assert extra_params are forwarded alongside toolkit.
assert_eq!(body["toolkit"].as_str(), Some("whatsapp"));
assert_eq!(body["waba_id"].as_str(), Some("waba-123"));
Json(json!({
"success": true,
"data": {
"connectUrl": "https://composio.example/whatsapp/consent",
"connectionId": "conn-xyz"
}
}))
}),
);
let base = start_mock_backend(app).await;
let client = build_client_for(base);
let extra = serde_json::json!({ "waba_id": "waba-123" });
let resp = client.authorize("whatsapp", Some(extra)).await.unwrap();
assert!(resp.connect_url.contains("whatsapp"));
assert_eq!(resp.connection_id, "conn-xyz");
}
#[tokio::test]
async fn list_tools_filters_pass_through_as_csv_query_param() {
let app = Router::new().route(
+3 -2
View File
@@ -97,11 +97,12 @@ pub async fn composio_list_connections(
pub async fn composio_authorize(
config: &Config,
toolkit: &str,
extra_params: Option<serde_json::Value>,
) -> OpResult<RpcOutcome<ComposioAuthorizeResponse>> {
tracing::debug!(toolkit = %toolkit, "[composio] rpc authorize");
tracing::debug!(toolkit = %toolkit, has_extra_params = extra_params.is_some(), "[composio] rpc authorize");
let client = resolve_client(config)?;
let resp = client
.authorize(toolkit)
.authorize(toolkit, extra_params)
.await
.map_err(|e| format!("[composio] authorize failed: {e:#}"))?;
+4 -2
View File
@@ -69,7 +69,9 @@ async fn composio_list_connections_errors_without_session() {
async fn composio_authorize_errors_without_session() {
let tmp = tempfile::tempdir().unwrap();
let config = test_config(&tmp);
let err = composio_authorize(&config, "gmail").await.unwrap_err();
let err = composio_authorize(&config, "gmail", None)
.await
.unwrap_err();
assert!(err.contains("composio unavailable"));
}
@@ -291,7 +293,7 @@ async fn composio_authorize_via_mock_publishes_event_and_returns_url() {
let base = start_mock_backend(app).await;
let tmp = tempfile::tempdir().unwrap();
let config = config_with_backend(&tmp, base);
let outcome = composio_authorize(&config, "gmail").await.unwrap();
let outcome = composio_authorize(&config, "gmail", None).await.unwrap();
assert_eq!(outcome.value.connect_url, "https://x");
assert_eq!(outcome.value.connection_id, "c1");
}
+18 -7
View File
@@ -188,12 +188,22 @@ pub fn schemas(function: &str) -> ControllerSchema {
namespace: "composio",
function: "authorize",
description: "Begin an OAuth handoff for a toolkit and return the hosted connect URL.",
inputs: vec![FieldSchema {
name: "toolkit",
ty: TypeSchema::String,
comment: "Toolkit slug to authorize (must be in the backend allowlist).",
required: true,
}],
inputs: vec![
FieldSchema {
name: "toolkit",
ty: TypeSchema::String,
comment: "Toolkit slug to authorize (must be in the backend allowlist).",
required: true,
},
FieldSchema {
name: "extra_params",
ty: TypeSchema::Json,
comment: "Optional JSON object of additional auth fields forwarded to Composio \
(e.g. {\"waba_id\": \"...\") for toolkits that require them). \
Reserved keys (toolkit, toolkit_version, auth, client_id) are rejected.",
required: false,
},
],
outputs: vec![
FieldSchema {
name: "connectUrl",
@@ -601,7 +611,8 @@ fn handle_authorize(params: Map<String, Value>) -> ControllerFuture {
Box::pin(async move {
let config = config_rpc::load_config_with_timeout().await?;
let toolkit = read_required_non_empty(&params, "toolkit")?;
to_json(super::ops::composio_authorize(&config, &toolkit).await?)
let extra_params = params.get("extra_params").cloned();
to_json(super::ops::composio_authorize(&config, &toolkit, extra_params).await?)
})
}
+1 -1
View File
@@ -422,7 +422,7 @@ impl Tool for ComposioAuthorizeTool {
));
}
tracing::debug!(toolkit = %toolkit, "[composio] tool authorize.execute");
match self.client.authorize(&toolkit).await {
match self.client.authorize(&toolkit, None).await {
Ok(resp) => {
crate::core::event_bus::publish_global(
crate::core::event_bus::DomainEvent::ComposioConnectionCreated {