Files
multica/server/internal/integrations/composio/service.go
Multica Eve 220fa58264 fix: guide SSH installs to token login (#5318)
Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-13 16:42:46 +08:00

710 lines
28 KiB
Go

// Package composio is the Stage 2 business-integration glue between Multica and
// the standalone Composio SDK (server/pkg/composio). It owns Multica semantics:
// the signed-state connect handshake, the local user_composio_connection
// mirror, idempotent disconnect, and the per-user MCP session helper.
//
// It deliberately does NOT wrap the SDK in another HTTP client — it composes
// *sdk.Client directly through the SDK interface so tests can drop in a fake.
//
// MVP scope (MUL-3720): toolkits are discovered dynamically. The
// toolkit→auth-config mapping is resolved at request time from Composio's
// /auth_configs endpoint (cached briefly), so a toolkit becomes connectable the
// moment an auth config is enabled for it in the Composio dashboard — no env
// var and no redeploy. A toolkit with no enabled auth config is rejected.
package composio
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"sync"
"time"
sdk "github.com/multica-ai/multica/server/pkg/composio"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// Service-level errors surfaced to the handler layer.
var (
// ErrToolkitNotSupported is returned by BeginConnect when the requested
// toolkit has no enabled auth config in the Composio project, so there is
// no auth_config_id to start a connect link with.
ErrToolkitNotSupported = errors.New("composio: toolkit not supported")
// ErrConnectNotSuccessful is returned by CompleteCallback when Composio
// reported a non-success status — no active row is written.
ErrConnectNotSuccessful = errors.New("composio: connection was not successful")
// ErrConnectionNotFound is returned by Disconnect when the connection id
// does not belong to the user (or does not exist).
ErrConnectionNotFound = errors.New("composio: connection not found")
// ErrAccountVerification is returned by CompleteCallback when the
// connected_account_id carried on the callback cannot be confirmed (with
// Composio) to belong to the user/auth-config named in the signed state —
// i.e. a tampered or unknown account id. No local row is written.
ErrAccountVerification = errors.New("composio: connected account verification failed")
)
// defaultStateTTL bounds how long a connect handshake may sit between
// BeginConnect and the Composio callback. Five minutes is generous for a hosted
// OAuth flow while keeping the replay window small.
const defaultStateTTL = 5 * time.Minute
// defaultAuthCacheTTL bounds how long the resolved toolkit→auth-config map is
// cached before a re-fetch from Composio. Short enough that enabling an auth
// config in the dashboard reflects within minutes; long enough that a burst of
// connect/list requests does not hammer /auth_configs.
const defaultAuthCacheTTL = 5 * time.Minute
// maxAuthConfigPages / maxToolkitPages cap the paginated fetch-all loops so a
// pathological or buggy upstream cursor cannot spin forever. At limit=1000 per
// page these cover far more than any real project / catalog.
const (
maxAuthConfigPages = 20
maxToolkitPages = 20
listPageLimit = 1000
composioLogoBaseURL = "https://logos.composio.dev/api"
)
// SDK is the subset of *sdk.Client the service depends on. Declared as an
// interface so handler/service tests can inject a fake without hitting Composio.
// *sdk.Client satisfies it.
type SDK interface {
CreateLink(ctx context.Context, req sdk.CreateLinkRequest) (*sdk.CreateLinkResponse, error)
ListConnectedAccounts(ctx context.Context, req sdk.ListConnectedAccountsRequest) (*sdk.ListConnectedAccountsResponse, error)
ListAuthConfigs(ctx context.Context, req sdk.ListAuthConfigsRequest) (*sdk.ListAuthConfigsResponse, error)
ListToolkits(ctx context.Context, req sdk.ListToolkitsRequest) (*sdk.ListToolkitsResponse, error)
RevokeConnection(ctx context.Context, connectedAccountID string) error
DeleteConnectedAccount(ctx context.Context, connectedAccountID string) error
CreateSession(ctx context.Context, req sdk.CreateSessionRequest) (*sdk.CreateSessionResponse, error)
MCPAuthHeaders() map[string]string
}
// Store is the persistence seam for the local connection mirror. *db.Queries
// satisfies it; tests use an in-memory fake.
type Store interface {
UpsertUserComposioConnection(ctx context.Context, arg db.UpsertUserComposioConnectionParams) (db.UserComposioConnection, error)
ListActiveUserComposioConnections(ctx context.Context, userID pgtype.UUID) ([]db.UserComposioConnection, error)
GetUserComposioConnection(ctx context.Context, arg db.GetUserComposioConnectionParams) (db.UserComposioConnection, error)
MarkUserComposioConnectionRevoked(ctx context.Context, arg db.MarkUserComposioConnectionRevokedParams) error
}
// Config configures a Service.
type Config struct {
// StateSecret signs the connect-state HMAC. Required (non-empty).
StateSecret []byte
// CallbackBaseURL is the absolute, public base URL of THIS API, with no
// trailing slash (e.g. "https://multica.ai"). The Composio callback
// URL is built as CallbackBaseURL + CallbackPath. Required.
CallbackBaseURL string
// FrontendBaseURL is the web app base used to build the post-callback
// browser redirect (e.g. "https://multica.ai"). May be empty, in which
// case CallbackRedirect returns a site-relative path.
FrontendBaseURL string
// StateTTL overrides the default connect-state lifetime. Zero uses
// defaultStateTTL.
StateTTL time.Duration
// AuthConfigTTL overrides how long the resolved toolkit→auth-config map is
// cached. Zero uses defaultAuthCacheTTL.
AuthConfigTTL time.Duration
// Now is overridable for deterministic tests. Nil uses time.Now.
Now func() time.Time
}
// callbackPath is the API path Composio redirects the browser back to. It is a
// constant (not configurable) so the SDK callback URL and the router route
// cannot drift apart.
const callbackPath = "/api/integrations/composio/callback"
// Service is the Composio business-integration service.
type Service struct {
sdk SDK
store Store
secret []byte
callbackURL string
frontendURL string
stateTTL time.Duration
now func() time.Time
// authCache holds the resolved toolkit_slug → auth_config_id map for the
// project. It is rebuilt from Composio's /auth_configs endpoint on first
// use and whenever authCacheExp has passed; authCacheMu guards both fields.
authCacheMu sync.Mutex
authCache map[string]string
authCacheExp time.Time
authCacheTTL time.Duration
}
// NewService validates its inputs and returns a ready Service. It errors when a
// required dependency is missing so a misconfigured boot fails loudly instead
// of returning 500s at request time.
func NewService(client SDK, store Store, cfg Config) (*Service, error) {
if client == nil {
return nil, errors.New("composio: SDK client is required")
}
if store == nil {
return nil, errors.New("composio: store is required")
}
if len(cfg.StateSecret) == 0 {
return nil, errors.New("composio: StateSecret is required")
}
base := strings.TrimRight(strings.TrimSpace(cfg.CallbackBaseURL), "/")
if base == "" {
return nil, errors.New("composio: CallbackBaseURL is required")
}
ttl := cfg.StateTTL
if ttl <= 0 {
ttl = defaultStateTTL
}
authTTL := cfg.AuthConfigTTL
if authTTL <= 0 {
authTTL = defaultAuthCacheTTL
}
now := cfg.Now
if now == nil {
now = time.Now
}
return &Service{
sdk: client,
store: store,
secret: cfg.StateSecret,
callbackURL: base + callbackPath,
frontendURL: strings.TrimRight(strings.TrimSpace(cfg.FrontendBaseURL), "/"),
stateTTL: ttl,
now: now,
authCacheTTL: authTTL,
}, nil
}
// Connection is the API-facing view of a local connection row. The Composio
// connected_account_id and auth_config_id are intentionally omitted — they are
// server-internal handles, not API surface.
type Connection struct {
ID string `json:"id"`
ToolkitSlug string `json:"toolkit_slug"`
Status string `json:"status"`
ConnectedAt string `json:"connected_at"`
LastUsedAt *string `json:"last_used_at"`
}
// MCPSession is the result of CreateMCPSession: the streamable MCP URL plus the
// headers an MCP client must attach. Headers carry the Composio x-api-key, so
// callers must route them through the redact pipeline before logging.
type MCPSession struct {
URL string
Headers map[string]string
}
// ToolkitView is the API-facing descriptor for one Composio toolkit, carrying
// exactly the fields the Settings UI renders plus a Connectable flag.
//
// Connectable means the project has an enabled auth config for the toolkit, so
// BeginConnect would succeed. Since MUL-4009 ListToolkits only returns
// connectable toolkits, so this is always true on the wire; the field is
// retained for backward compatibility with older desktop clients that branch on
// it (removing it would make them treat every entry as non-connectable).
type ToolkitView struct {
Slug string `json:"slug"`
Name string `json:"name"`
LogoURL string `json:"logo,omitempty"`
Category string `json:"category,omitempty"`
Connectable bool `json:"connectable"`
}
func toolkitLogoURL(slug, upstreamLogoURL string) string {
if upstreamLogoURL != "" {
return upstreamLogoURL
}
slug = strings.ToLower(strings.TrimSpace(slug))
if slug == "" {
return ""
}
return composioLogoBaseURL + "/" + url.PathEscape(slug)
}
// BeginConnect validates the toolkit, mints a signed state, and asks Composio
// for a hosted Connect Link. The returned redirect URL is where the caller
// sends the user's browser.
//
// The auth_config_id is resolved dynamically from the project's enabled auth
// configs (cached), so a toolkit is connectable iff the dashboard has an auth
// config for it — no static env map. A toolkit with none yields
// ErrToolkitNotSupported.
//
// The composio_user_id sent to Composio is the Multica user id verbatim — the
// invariant the rest of the integration relies on.
func (s *Service) BeginConnect(ctx context.Context, userID pgtype.UUID, toolkitSlug string) (string, error) {
slug := strings.ToLower(strings.TrimSpace(toolkitSlug))
authConfigID, err := s.authConfigForToolkit(ctx, slug)
if err != nil {
return "", err
}
if authConfigID == "" {
return "", ErrToolkitNotSupported
}
if !userID.Valid {
return "", errors.New("composio: invalid user id")
}
composioUserID := util.UUIDToString(userID)
state, err := signState(s.secret, stateClaims{
UserID: composioUserID,
ToolkitSlug: slug,
AuthConfigID: authConfigID,
Exp: s.now().Add(s.stateTTL).Unix(),
})
if err != nil {
return "", fmt.Errorf("composio: sign state: %w", err)
}
// Composio appends its own status / connected_account_id query params to
// the callback URL and preserves ours, so the signed state rides back to us
// on the redirect.
callbackURL := s.callbackURL + "?state=" + url.QueryEscape(state)
resp, err := s.sdk.CreateLink(ctx, sdk.CreateLinkRequest{
AuthConfigID: authConfigID,
UserID: composioUserID,
CallbackURL: callbackURL,
})
if err != nil {
return "", fmt.Errorf("composio: create link: %w", err)
}
return resp.RedirectURL, nil
}
// CompleteCallback verifies the signed state and, on a successful Composio
// status, upserts the local connection row. It returns the toolkit slug from
// the state so the handler can build the right redirect even on the
// not-successful path.
//
// Idempotency: the upsert is keyed on (user_id, connected_account_id), so a
// duplicate callback re-activates the same row instead of creating a second.
func (s *Service) CompleteCallback(ctx context.Context, state, status, connectedAccountID string) (string, error) {
claims, err := verifyState(s.secret, state, s.now())
if err != nil {
return "", err
}
if !strings.EqualFold(strings.TrimSpace(status), "success") {
// Honor the state for the redirect slug, but do not write an active row.
return claims.ToolkitSlug, ErrConnectNotSuccessful
}
if strings.TrimSpace(connectedAccountID) == "" {
return claims.ToolkitSlug, errors.New("composio: callback missing connected_account_id")
}
userID, err := util.ParseUUID(claims.UserID)
if err != nil {
return claims.ToolkitSlug, fmt.Errorf("composio: state has invalid user id: %w", err)
}
// The auth_config_id was resolved at BeginConnect and signed into the state,
// so we compare against THAT exact value rather than re-resolving here (a
// re-resolve that failed or drifted would otherwise fail-open: a missing
// expected auth config used to skip the check, letting another toolkit's
// account id be bound under this toolkit's slug). An empty value fails
// closed in verifyAccountOwnership.
authConfigID := claims.AuthConfigID
// Defense-in-depth (PR 4608 review): the signed state proves *who* started
// the handshake and *which* toolkit, but connected_account_id rides back as
// a plain query param Composio appends to our callback URL. A crafted
// redirect could pair a valid, un-expired state with someone else's account
// id and we would mirror it verbatim. Before writing, confirm with Composio
// that this account actually belongs to the state's user (the
// composio_user_id == multica user id invariant) and was created under the
// toolkit's auth config. Any mismatch fails closed with ErrAccountVerification.
if err := s.verifyAccountOwnership(ctx, connectedAccountID, claims.UserID, authConfigID); err != nil {
return claims.ToolkitSlug, err
}
if _, err := s.store.UpsertUserComposioConnection(ctx, db.UpsertUserComposioConnectionParams{
UserID: userID,
ToolkitSlug: claims.ToolkitSlug,
AuthConfigID: authConfigID,
ConnectedAccountID: connectedAccountID,
// Invariant: composio_user_id == Multica user id.
ComposioUserID: claims.UserID,
}); err != nil {
return claims.ToolkitSlug, fmt.Errorf("composio: upsert connection: %w", err)
}
return claims.ToolkitSlug, nil
}
// ListConnections returns the user's active connections.
func (s *Service) ListConnections(ctx context.Context, userID pgtype.UUID) ([]Connection, error) {
rows, err := s.store.ListActiveUserComposioConnections(ctx, userID)
if err != nil {
return nil, err
}
out := make([]Connection, 0, len(rows))
for _, row := range rows {
out = append(out, rowToConnection(row))
}
return out, nil
}
// Disconnect revokes and deletes the connection at Composio, then marks the
// local row revoked. It is idempotent: a Composio 404 (already gone) is treated
// as success, and re-revoking an already-revoked local row is a no-op.
//
// A connection id that does not belong to the user (or does not exist at all)
// returns ErrConnectionNotFound so the handler can 404 without leaking
// existence across users.
func (s *Service) Disconnect(ctx context.Context, userID, connectionID pgtype.UUID) error {
row, err := s.store.GetUserComposioConnection(ctx, db.GetUserComposioConnectionParams{
ID: connectionID,
UserID: userID,
})
if err != nil {
// pgx.ErrNoRows or fake "not found" — treat as not found for the owner.
return ErrConnectionNotFound
}
// Already disconnected locally: a repeat DELETE is a pure no-op. Short-
// circuiting here keeps disconnect idempotent even when the upstream now
// answers revoke/delete with a NON-404 error (PR 4608 review): the account
// is already gone, so re-hitting Composio could turn a second DELETE into a
// 502 and break the 204-idempotent contract. The first disconnect already
// revoked upstream and marked the row.
if !strings.EqualFold(row.Status, "active") {
return nil
}
// Revoke the upstream grant first, then delete the Composio record. Both are
// made idempotent against a 404 so a repeated disconnect (or a connection
// already removed at Composio) still resolves the local row.
if err := s.sdk.RevokeConnection(ctx, row.ConnectedAccountID); err != nil && !isNotFound(err) {
return fmt.Errorf("composio: revoke connection: %w", err)
}
// DeleteConnectedAccount already swallows 404 in the SDK, but guard anyway.
if err := s.sdk.DeleteConnectedAccount(ctx, row.ConnectedAccountID); err != nil && !isNotFound(err) {
return fmt.Errorf("composio: delete connected account: %w", err)
}
if err := s.store.MarkUserComposioConnectionRevoked(ctx, db.MarkUserComposioConnectionRevokedParams{
ID: connectionID,
UserID: userID,
}); err != nil {
return fmt.Errorf("composio: mark revoked: %w", err)
}
return nil
}
// CreateMCPSession opens a Composio tool-router (MCP) session scoped to the
// user's active connections. It returns (nil, nil) when the user has no active
// connections — callers treat that as "no MCP overlay for this user".
//
// connected_accounts is pinned per toolkit to the user's own connected account
// id array so the session cannot surface accounts the user did not connect. This
// helper is NOT yet wired into task dispatch (Stage 3); it exists so that wiring
// is a pure consumer of an already-tested seam.
//
// Single-account constraint (v1, PR 4608 review follow-up): the MVP connect
// flow assumes AT MOST ONE active connection per (user, toolkit) — there is no
// UI or API to hold several. Should duplicates ever exist, we must choose
// deterministically: rows arrive newest-first (ListActive orders by
// connected_at DESC), so we keep the FIRST occurrence per toolkit (the most
// recently connected account) instead of letting a later map write silently
// select an older one.
//
// Stage 3 owns the real decision before this is wired into dispatch: either
// enforce the single-active constraint at connect time (revoke the previous
// active row for the same toolkit on a new connect) or extend CreateSession to
// a multi-account request shape. Until then, newest-wins keeps behavior
// deterministic rather than order-dependent.
func (s *Service) CreateMCPSession(ctx context.Context, userID pgtype.UUID) (*MCPSession, error) {
rows, err := s.store.ListActiveUserComposioConnections(ctx, userID)
if err != nil {
return nil, err
}
if len(rows) == 0 {
return nil, nil
}
connectedAccounts := make(map[string]any, len(rows))
for _, row := range rows {
// Keep the first (newest) account per toolkit; ignore older duplicates.
if _, exists := connectedAccounts[row.ToolkitSlug]; exists {
continue
}
connectedAccounts[row.ToolkitSlug] = []string{row.ConnectedAccountID}
}
resp, err := s.sdk.CreateSession(ctx, sdk.CreateSessionRequest{
UserID: util.UUIDToString(userID),
ConnectedAccounts: connectedAccounts,
})
if err != nil {
return nil, fmt.Errorf("composio: create session: %w", err)
}
return &MCPSession{
URL: resp.MCP.URL,
Headers: s.sdk.MCPAuthHeaders(),
}, nil
}
// CallbackRedirect builds the browser redirect target for the callback handler.
// On success it points at the settings page (Integrations tab) with the
// connected toolkit slug; on failure it carries a stable error code. The path
// is the slug-less `/settings?tab=integrations&...` form on purpose: the web
// proxy's legacy-route redirect prepends the user's last workspace slug, so it
// resolves to the real `/{slug}/settings?tab=integrations` route that mounts
// the Composio tab. The older `/settings/integrations` path was NOT a real
// route and 404'd after the legacy rewrite. When FrontendBaseURL is unset it
// returns a site-relative path.
func (s *Service) CallbackRedirect(slug string, success bool) string {
var path string
if success {
path = "/settings?tab=integrations&connected=" + url.QueryEscape(slug)
} else {
path = "/settings?tab=integrations&error=composio_connect_failed"
}
return s.frontendURL + path
}
// rowToConnection maps a DB row to the API-facing Connection view.
func rowToConnection(row db.UserComposioConnection) Connection {
c := Connection{
ID: util.UUIDToString(row.ID),
ToolkitSlug: row.ToolkitSlug,
Status: row.Status,
}
if row.ConnectedAt.Valid {
c.ConnectedAt = row.ConnectedAt.Time.UTC().Format(time.RFC3339)
}
c.LastUsedAt = util.TimestampToPtr(row.LastUsedAt)
return c
}
// ListToolkits returns only the Composio toolkits the project can actually
// connect (those with an enabled auth config). Toolkits with no enabled auth
// config are filtered out entirely (MUL-4009): a card the user can't act on is
// noise, and the old "Not configured" grey label existed only to avoid a dead
// button — dropping the entry removes the need for it. It fetches all pages
// (capped by maxToolkitPages) so the UI gets the complete connectable list in
// one call; the catalog is a few hundred entries, well within a single JSON
// response. Every returned entry has Connectable == true, preserving Composio's
// usage order.
//
// Unlike the previous behavior, a resolver error is NOT masked into an
// everything-not-connectable catalog: with filtering in place that would render
// as a silent "no apps configured" empty state, which is misleading. We return
// the error so the handler can surface a 502 and the UI can show its honest
// load-failed state instead.
func (s *Service) ListToolkits(ctx context.Context) ([]ToolkitView, error) {
// connectable is the project's enabled toolkit_slug → auth_config_id map.
// It is the source of truth for what to surface: only slugs present here
// are returned.
connectable, err := s.authConfigMap(ctx)
if err != nil {
return nil, fmt.Errorf("composio: resolve connectable toolkits: %w", err)
}
out := []ToolkitView{}
seen := make(map[string]struct{})
cursor := ""
for page := 0; page < maxToolkitPages; page++ {
resp, err := s.sdk.ListToolkits(ctx, sdk.ListToolkitsRequest{
Limit: listPageLimit,
Cursor: cursor,
SortBy: "usage",
})
if err != nil {
return nil, fmt.Errorf("composio: list toolkits: %w", err)
}
for _, tk := range resp.Items {
slug := strings.ToLower(strings.TrimSpace(tk.Slug))
if slug == "" {
continue
}
if _, dup := seen[slug]; dup {
continue
}
seen[slug] = struct{}{}
// Filter out toolkits with no enabled auth config: the user has no
// working action for them, so they are omitted from the catalog.
if _, canConnect := connectable[slug]; !canConnect {
continue
}
category := ""
if len(tk.Categories) > 0 {
category = tk.Categories[0]
}
out = append(out, ToolkitView{
Slug: tk.Slug,
Name: tk.Name,
LogoURL: toolkitLogoURL(slug, tk.LogoURL),
Category: category,
// Every surfaced toolkit is connectable by construction. The
// wire field is kept (see ComposioToolkitResponse) for backward
// compatibility with older desktop clients that branch on it.
Connectable: true,
})
}
if resp.NextCursor == "" {
break
}
cursor = resp.NextCursor
}
return out, nil
}
// authConfigForToolkit returns the chosen auth_config_id for a toolkit slug, or
// "" when the project has no enabled auth config for it. It reads the cached
// project-wide map (refreshed on TTL).
func (s *Service) authConfigForToolkit(ctx context.Context, slug string) (string, error) {
slug = strings.ToLower(strings.TrimSpace(slug))
if slug == "" {
return "", nil
}
m, err := s.authConfigMap(ctx)
if err != nil {
return "", err
}
return m[slug], nil
}
// authConfigMap returns the toolkit_slug → auth_config_id map for the project,
// rebuilding it from Composio when the cache is empty or expired. Concurrent
// callers serialize on authCacheMu; the fetch runs under the lock, which is
// acceptable for a short-TTL map that is cheap to build and read by a
// low-traffic settings surface. A new map is assigned on refresh (never mutated
// in place), so a reference handed to a caller stays safe to read.
func (s *Service) authConfigMap(ctx context.Context) (map[string]string, error) {
s.authCacheMu.Lock()
defer s.authCacheMu.Unlock()
if s.authCache != nil && s.now().Before(s.authCacheExp) {
return s.authCache, nil
}
m, err := s.fetchAuthConfigMap(ctx)
if err != nil {
// Serve a stale snapshot if we have one — a transient /auth_configs
// blip should not make every toolkit suddenly un-connectable.
if s.authCache != nil {
return s.authCache, nil
}
return nil, err
}
s.authCache = m
s.authCacheExp = s.now().Add(s.authCacheTTL)
return m, nil
}
// authCandidate is one project auth config in contention to represent a toolkit
// during resolution.
type authCandidate struct {
id string
managed bool
updated string
}
// fetchAuthConfigMap pages through the project's ENABLED auth configs and
// reduces them to one chosen auth_config_id per toolkit slug. When a toolkit
// has several (e.g. a Composio-managed one plus a custom white-label one),
// betterAuthConfig picks the winner.
func (s *Service) fetchAuthConfigMap(ctx context.Context) (map[string]string, error) {
best := make(map[string]authCandidate)
cursor := ""
for page := 0; page < maxAuthConfigPages; page++ {
resp, err := s.sdk.ListAuthConfigs(ctx, sdk.ListAuthConfigsRequest{
ShowDisabled: false,
Limit: listPageLimit,
Cursor: cursor,
})
if err != nil {
return nil, fmt.Errorf("composio: list auth configs: %w", err)
}
for _, ac := range resp.Items {
if ac.ID == "" || strings.EqualFold(ac.Status, "DISABLED") {
continue
}
slug := strings.ToLower(strings.TrimSpace(ac.Toolkit.Slug))
if slug == "" {
continue
}
cand := authCandidate{id: ac.ID, managed: ac.IsComposioManaged, updated: ac.LastUpdatedAt}
if cur, ok := best[slug]; !ok || betterAuthConfig(cand, cur) {
best[slug] = cand
}
}
if resp.NextCursor == "" {
break
}
cursor = resp.NextCursor
}
out := make(map[string]string, len(best))
for slug, c := range best {
out[slug] = c.id
}
return out, nil
}
// betterAuthConfig reports whether candidate a should win over the currently
// selected b for the same toolkit. A custom (bring-your-own OAuth) config beats
// a Composio-managed one — it is the white-label path the product wants — and
// among configs of the same kind the most recently updated wins.
func betterAuthConfig(a, b authCandidate) bool {
if a.managed != b.managed {
return !a.managed
}
return a.updated > b.updated
}
// verifyAccountOwnership confirms with Composio that connectedAccountID really
// belongs to expectedUserID and was created under expectedAuthConfigID, so a
// tampered or cross-toolkit connected_account_id on the callback cannot smuggle
// another account into the local mirror. It fails closed: an upstream error, an
// unknown account, an owner mismatch, an EMPTY expected auth config, or an
// auth-config mismatch all return ErrAccountVerification. Requiring a non-empty,
// exactly-matching auth config is what closes the cross-toolkit binding gap —
// the expected value is the auth_config_id signed into the state at
// BeginConnect, which is toolkit-specific.
func (s *Service) verifyAccountOwnership(ctx context.Context, connectedAccountID, expectedUserID, expectedAuthConfigID string) error {
resp, err := s.sdk.ListConnectedAccounts(ctx, sdk.ListConnectedAccountsRequest{
ConnectedAccountIDs: []string{connectedAccountID},
})
if err != nil {
return fmt.Errorf("composio: verify connected account: %w", err)
}
var acct *sdk.ConnectedAccount
for i := range resp.Items {
if resp.Items[i].ID == connectedAccountID {
acct = &resp.Items[i]
break
}
}
if acct == nil {
return ErrAccountVerification
}
if acct.UserID != expectedUserID {
return ErrAccountVerification
}
// Fail closed: the account MUST have been created under the exact auth
// 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.
accountAuthConfigID := acct.AuthConfigID
if accountAuthConfigID == "" {
accountAuthConfigID = acct.AuthConfig.ID
}
if expectedAuthConfigID == "" || accountAuthConfigID != expectedAuthConfigID {
return ErrAccountVerification
}
return nil
}
// isNotFound reports whether err is a Composio 404 APIError, used to make
// revoke/delete idempotent.
func isNotFound(err error) bool {
var apiErr *sdk.APIError
return errors.As(err, &apiErr) && apiErr.IsNotFound()
}