From cdc67694ce47d8951e7e253288a216a2aedade0c Mon Sep 17 00:00:00 2001 From: yushen Date: Tue, 30 Jun 2026 13:53:18 +0800 Subject: [PATCH] fix(composio): accept nested connected account auth config --- .../internal/integrations/composio/service.go | 6 ++- .../integrations/composio/service_test.go | 35 +++++++++++++--- server/pkg/composio/client_test.go | 42 +++++++++++++++++++ server/pkg/composio/connected_accounts.go | 10 +++++ 4 files changed, 87 insertions(+), 6 deletions(-) diff --git a/server/internal/integrations/composio/service.go b/server/internal/integrations/composio/service.go index 7d8450bda..8579b5b27 100644 --- a/server/internal/integrations/composio/service.go +++ b/server/internal/integrations/composio/service.go @@ -671,7 +671,11 @@ func (s *Service) verifyAccountOwnership(ctx context.Context, connectedAccountID // config the connect link used. An empty expected value (state missing it, // or a resolver gap) is rejected rather than skipped — skipping is the // fail-open hole that let a cross-toolkit account id be bound here. - if expectedAuthConfigID == "" || acct.AuthConfigID != expectedAuthConfigID { + accountAuthConfigID := acct.AuthConfigID + if accountAuthConfigID == "" { + accountAuthConfigID = acct.AuthConfig.ID + } + if expectedAuthConfigID == "" || accountAuthConfigID != expectedAuthConfigID { return ErrAccountVerification } return nil diff --git a/server/internal/integrations/composio/service_test.go b/server/internal/integrations/composio/service_test.go index 108d1f6c7..0610a8ad4 100644 --- a/server/internal/integrations/composio/service_test.go +++ b/server/internal/integrations/composio/service_test.go @@ -34,11 +34,12 @@ type fakeSDK struct { // ListConnectedAccounts echoes the requested id with acctUserID / // acctAuthConfigID so success-path tests can opt in to a matching account; // acctMissing returns no items, listAccountsErr forces a transport error. - acctUserID string - acctAuthConfigID string - acctMissing bool - listAccountsErr error - lastListAccounts sdk.ListConnectedAccountsRequest + acctUserID string + acctAuthConfigID string + acctNestedAuthConfigID string + acctMissing bool + listAccountsErr error + lastListAccounts sdk.ListConnectedAccountsRequest // auth-config resolution (BeginConnect / ListToolkits connectable flag). // authConfigs nil => a default single notion→ac_notion ENABLED config so // existing connect tests keep resolving; set explicitly to override. @@ -77,6 +78,7 @@ func (f *fakeSDK) ListConnectedAccounts(_ context.Context, req sdk.ListConnected ID: id, UserID: f.acctUserID, AuthConfigID: f.acctAuthConfigID, + AuthConfig: sdk.AuthConfigRef{ID: f.acctNestedAuthConfigID}, }}}, nil } @@ -405,6 +407,29 @@ func TestCompleteCallback_SuccessAndIdempotent(t *testing.T) { } } +func TestCompleteCallback_AcceptsNestedAuthConfig(t *testing.T) { + t.Parallel() + store := newFakeStore() + userID := mintUUID(30) + // Composio v3.1 returns connected-account auth config under auth_config.id, + // not always as a top-level auth_config_id. + sdkFake := &fakeSDK{acctUserID: util.UUIDToString(userID), acctNestedAuthConfigID: "ac_notion"} + svc := newTestService(t, sdkFake, store) + state, _ := signState(testSecret, stateClaims{ + UserID: util.UUIDToString(userID), + ToolkitSlug: "notion", + AuthConfigID: "ac_notion", + Exp: time.Unix(1_700_000_000, 0).Add(time.Minute).Unix(), + }) + + if _, err := svc.CompleteCallback(context.Background(), state, "success", "ca_nested"); err != nil { + t.Fatalf("CompleteCallback: %v", err) + } + if len(store.rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(store.rows)) + } +} + func TestCompleteCallback_NonSuccessNoRow(t *testing.T) { t.Parallel() store := newFakeStore() diff --git a/server/pkg/composio/client_test.go b/server/pkg/composio/client_test.go index b5ecc8599..93feeb906 100644 --- a/server/pkg/composio/client_test.go +++ b/server/pkg/composio/client_test.go @@ -288,6 +288,48 @@ func TestListConnectedAccounts_QueryString(t *testing.T) { } } +func TestListConnectedAccounts_ParsesNestedAuthConfig(t *testing.T) { + c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, map[string]any{ + "items": []map[string]any{ + { + "id": "ca_nested", + "user_id": "u_1", + "auth_config": map[string]any{ + "id": "ac_nested", + "auth_scheme": "OAUTH2", + "is_composio_managed": true, + }, + "toolkit": map[string]any{"slug": "notion"}, + "status": "ACTIVE", + }, + { + "id": "ca_top_level", + "user_id": "u_1", + "auth_config_id": "ac_top_level", + "auth_config": map[string]any{"id": "ac_nested_ignored"}, + "toolkit": map[string]any{"slug": "gmail"}, + "status": "ACTIVE", + }, + }, + }) + }) + + resp, err := c.ListConnectedAccounts(context.Background(), composio.ListConnectedAccountsRequest{}) + if err != nil { + t.Fatalf("ListConnectedAccounts: %v", err) + } + if got := resp.Items[0].AuthConfig.ID; got != "ac_nested" { + t.Errorf("nested auth config id = %q, want ac_nested", got) + } + if got := resp.Items[0].AuthConfig.AuthScheme; got != "OAUTH2" { + t.Errorf("nested auth scheme = %q, want OAUTH2", got) + } + if got := resp.Items[1].AuthConfigID; got != "ac_top_level" { + t.Errorf("top-level auth config id = %q, want ac_top_level", got) + } +} + func TestRevokeConnection_Success(t *testing.T) { c, _ := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost || r.URL.Path != "/connected_accounts/ca_42/revoke" { diff --git a/server/pkg/composio/connected_accounts.go b/server/pkg/composio/connected_accounts.go index 5c8a912e3..47fdc74b4 100644 --- a/server/pkg/composio/connected_accounts.go +++ b/server/pkg/composio/connected_accounts.go @@ -91,6 +91,7 @@ type ConnectedAccount struct { ID string `json:"id"` UserID string `json:"user_id"` AuthConfigID string `json:"auth_config_id"` + AuthConfig AuthConfigRef `json:"auth_config"` Toolkit Toolkit `json:"toolkit"` Status string `json:"status"` StatusReason string `json:"status_reason,omitempty"` @@ -100,6 +101,15 @@ type ConnectedAccount struct { Extra map[string]any `json:"-"` } +// AuthConfigRef is the nested auth_config object Composio returns on connected +// accounts. Some responses also include the older top-level auth_config_id. +type AuthConfigRef struct { + ID string `json:"id"` + AuthScheme string `json:"auth_scheme,omitempty"` + IsComposioManaged bool `json:"is_composio_managed,omitempty"` + IsDisabled bool `json:"is_disabled,omitempty"` +} + // ListConnectedAccountsResponse is the typed paginated response. type ListConnectedAccountsResponse struct { Items []ConnectedAccount `json:"items"`