Skip to content

Let a deployment hold its own client for Composio apps that need one (Shopify) - #950

Merged
mxmzb merged 4 commits into
mainfrom
maxim/composio-deployment-client
Nov 4, 2026
Merged

mxmzb merged 4 commits into
mainfrom
maxim/composio-deployment-client

Conversation

@mxmzb

@mxmzb mxmzb commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator

What this changes

Fixes #885.

Composio publishes Shopify with OAUTH2 and S2S_OAUTH2 only, and manages no auth for it. OpenBot read that as unsupported, so Shopify was hidden from the picker and enabling it answered 503 ("needs an OAuth application registered by whoever runs this deployment…"). S2S_OAUTH2 is a client-credentials grant: Composio exchanges a client id and secret for the token itself, with no consent screen and no redirect. All OpenBot was missing was a place for the deployment's own client.

  • The client comes from the environment, per app: COMPOSIO_<SLUG>_CLIENT_ID / COMPOSIO_<SLUG>_CLIENT_SECRET (COMPOSIO_SHOPIFY_CLIENT_ID, …; a dash in the slug becomes _). Both or nothing: one without the other is no client. This lives in the new server/src/plugins/deployment-clients.ts.
  • S2S_OAUTH2 is a field scheme, but only while that client is held. It is added to FIELD_SCHEME_NAMES and ranks last in FIELD_SCHEME_ORDER, so an app that also takes a plain API_KEY still asks for the key. Managed consent still wins over it. connectionOf drops the scheme when the client is missing, so such an app stays unsupported and hidden as before. Its refusal now names the two variables to set.
  • The auth config is created with credentials: { client_id, client_secret } (ensureAuthConfig) rather than {}. The credentials type on the vendor seam is widened for this one case.
  • The connection is created without status: "ACTIVE" in state.val for S2S_OAUTH2 (connectWithFields). With it, Composio refuses (access_token: Required), because ACTIVE claims the token is already there. Without it, Composio performs the client-credentials exchange itself. On this branch a typed value named status is dropped, so the existing guarantee still holds: no form value can set the state Composio is asked for.
  • The app's own FIELD_SCHEMES in app/src/components/plugins/brokered-account-row.tsx gets the scheme too, so the row draws the connect form (for Shopify, Composio's own subdomain field) rather than sending the person to a consent screen.
  • Docs: docs/configuration.md, docs/plugins/composio.md ("The 56 that are not offered" now names this one exception) and .env.example.

Out of scope: the post-connect identity check, which can withdraw a Shopify connection whose token is fine, is #886 (outside PR #905). Nothing here touches IDENTITY_ACTION.

Verified live against Composio and a real Shopify dev store, using the same change on an older OpenBot build: enable, connect with the store subdomain, list orders, refund. That run is where the access_token: Required refusal with status: "ACTIVE", and the exchange succeeding without it, were observed.

Where it runs

  • New state that outlives a request? None. The client is read from the process environment, the same way COMPOSIO_API_KEY is. The auth config it creates lives at Composio, as every config already does.
  • What happens on the second replica? It reads the same environment. Every replica must be given the same pair, like COMPOSIO_API_KEY. A replica missing it would show the app as unsupported and refuse to enable it with a sentence naming the variables. It would not create a broken config.
  • Anything serialised? No. Config creation goes through the existing ensureAuthConfig path unchanged.
  • Anything fanned out to a browser? No.
  • New listener, port, or schedule? No.

Boundary and audit

  • Every acting call still goes through the gateway: resolve, decide, audit, then act. The connect and enable routes and their audit rows are unchanged; only what is sent to Composio differs for this one scheme.
  • New refusals and new failures each write a row. No new refusal path: an app without its client takes the existing unsupported refusal, now with a sentence naming the variables.
  • Nothing new is trusted from the client that the server can resolve itself. The OAuth client never comes from a request; it is read only from the server's environment and never shown or logged.

Changelog

  • A line in CHANGELOG.md under Unreleased ("Shopify and other Composio apps on a server-to-server grant can be connected"). Unset, nothing changes for an existing deployment.

Proof

  • New server/tests/composio-deployment-client.test.ts: with the client set, Shopify's row is { kind: "fields", authScheme: "S2S_OAUTH2" }. Without it, it is unsupported and the reason names COMPOSIO_SHOPIFY_CLIENT_ID and COMPOSIO_SHOPIFY_CLIENT_SECRET. Half a client is still unsupported. A plain API_KEY is still preferred, and managed consent still wins. Blank or half pairs read as no client, values are trimmed, and a dashed slug maps to an underscored variable.
  • Added to server/tests/composio-adapter.test.ts: the auth config for an S2S app is created with credentials: { client_id, client_secret }. The connection is created with val: { subdomain }: no status, and a typed status is dropped.
  • bun test server/tests against a fresh pgvector/pg17 database migrated with scripts/migrate.ts: 6176 pass, 18 skip, 5 fail. The 5 failures (browser-routes.integration, learning-mastra, learning-langgraph) fail identically on main in this environment (no built Recorder or Bot dependency trees) and are unrelated.
  • bun test app/tests/brokered-account-row.test.tsx app/tests/connection-fields.test.tsx app/tests/shared-connected-accounts.test.tsx: 51 pass.
  • bunx biome format and bunx biome lint --error-on-warnings on the changed files: clean. tsc --noEmit for server and app: clean. bun run typecheck:tests: clean against the baseline.
  • Not run here: composio-live (needs a Composio key), and the live Shopify flow on this exact branch (it was run on the older build the change was ported from).

🤖 Generated with Claude Code

…(Shopify)

Composio publishes Shopify with OAUTH2 and S2S_OAUTH2 only and no managed
auth, so OpenBot hid it from the picker and refused it at enable. S2S_OAUTH2
is a client-credentials grant: Composio exchanges a client id and secret for
the token itself, with no consent screen, so all it needs is the deployment's
own client.

That client is read from COMPOSIO_<SLUG>_CLIENT_ID and
COMPOSIO_<SLUG>_CLIENT_SECRET, both or nothing. While it is held,
S2S_OAUTH2 is offered as a field scheme, ranked after a plain key. The auth
config is created with the client as its credentials, and the connection is
created without status ACTIVE, which Composio refuses for the missing
access_token; left out, Composio performs the exchange itself. Without the
client the app stays unsupported, and the refusal names the two variables.

Fixes #885

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@guidovizoso guidovizoso left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mechanics look right. S2S_OAUTH2 is only offered while both variables are set, it ranks last behind a plain key, the connection is created without status: "ACTIVE", a typed status is dropped, and the secret stays in the server's environment and on the config at Composio. One thing I'd like resolved before this merges.

1. Who can connect an S2S app (blocking)

As far as I can tell, an app connected with typed values can be connected as a personal account by anyone who sees it enabled (connectBrokeredWithFields: "Connect this person with the secret they typed", with a holder on the account). That's fine for a key app, because the secret is the person's own. With S2S_OAUTH2, the person types only the store subdomain, and the credential is entirely the deployment's client. So any user could connect "their" Shopify and get the full store access of the organization's Shopify app, without an administrator granting it to them. The audit trail would then attribute that to them as their own account.

Could S2S apps be restricted to shared accounts that an administrator connects and then grants to Bots the usual way? If personal connections are intended, could you say why they're acceptable here?

2. Rotating the client (docs)

ensureAuthConfig reuses a standing config, so changing COMPOSIO_<SLUG>_CLIENT_SECRET later doesn't update the config already at Composio. Please document how to rotate it, for example by disabling and re-enabling the app or by updating the config at Composio.

3. Nit: the empty-client fallback

deploymentClientOf(toolkit) ?? {} would create an S2S config with no client if the variables disappeared between the directory read and the enable. The comment says that path isn't reachable. Refusing there would make that explicit rather than relying on the comment.

mxmzb and others added 2 commits November 4, 2026 15:48
…ment-client

# Conflicts:
#	docs/configuration.md
An S2S_OAUTH2 app signs in with the deployment's OAuth client, so a
personal account on it would be the organization's access under one
person's name. The connect route and the store now refuse a personal
account for such an app, the listing carries `sharedOnly`, and the
Connections and admin Plugins pages offer no personal Connect (and no
switch back to personal once shared).

Enabling an S2S app whose client variables have gone is refused by name
instead of creating a config with no client. docs/configuration.md says
how to rotate the client, since a standing config is reused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mxmzb

mxmzb commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks, all three addressed in 632186a:

  1. Who can connect. An app on the deployment's own client connects only as one shared account. The connect route refuses a personal account with 403 before the form is drawn, and the store refuses it again before any values travel, since it can be called without that route. The listing carries sharedOnly, so Connections and the admin Plugins page don't offer a personal Connect. An administrator connects it once and grants it to Bots the usual way.
  2. Rotating the client. docs/configuration.md has a new "Rotating a Composio app's own client" section, linked from the variable's row and from docs/plugins/composio.md.
  3. Empty client. Enabling an S2S app whose variables have gone is now refused by name, instead of creating a config with no client.

Could you take another look?

@mxmzb
mxmzb merged commit 2178a38 into main Nov 4, 2026
19 checks passed
@mxmzb
mxmzb deleted the maxim/composio-deployment-client branch November 4, 2026 17:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support Composio apps that need the deployment's own OAuth client (S2S_OAUTH2, e.g. Shopify)

2 participants