fix(composio): accept nested connected account auth config

This commit is contained in:
yushen
2026-06-30 13:53:18 +08:00
parent 66794fc4f3
commit cdc67694ce
4 changed files with 87 additions and 6 deletions

View File

@@ -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

View File

@@ -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()

View File

@@ -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" {

View File

@@ -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"`