From 2cf44a48a38490d0504baade7362b8b2a63655ce Mon Sep 17 00:00:00 2001 From: Sahil Shaikh Date: Tue, 29 Sep 2026 20:31:47 +0000 Subject: [PATCH 1/2] Support a per-app loopback OAuth callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some providers only accept a redirect_uri that is already registered on their own OAuth app — Slack's MCP server answers anything else with "redirect_uri did not match any configured URIs" — and such an app usually pins a loopback port this Executor could not offer. A registered app can now declare callbackPort (and optionally callbackPath, default /callback): the host binds http://127.0.0.1: for the flow and sends it as redirect_uri on both the authorization request and the token exchange, then forwards the provider's callback to the existing completion route. A flow for such an app is refused unless the caller passes that exact URI, which it reads from oauth.loopbackCallback before binding, so a caller that never asked for it fails loudly instead of handing back an authorization URL nothing is listening at. A listener shared by two concurrent flows is left to its TTL rather than closed by the first completion, so one sign-in cannot refuse the other's callback. Fixes #2153 --- .changeset/oauth-loopback-callback-port.md | 33 + apps/cloud/drizzle/0021_breezy_shadowcat.sql | 2 + apps/cloud/drizzle/meta/0021_snapshot.json | 1772 +++++++++++++++++ apps/cloud/drizzle/meta/_journal.json | 7 + apps/cloud/src/db/executor-schema.ts | 2 + .../drizzle/0007_flimsy_harry_osborn.sql | 2 + apps/local/drizzle/meta/0007_snapshot.json | 962 +++++++++ apps/local/drizzle/meta/_journal.json | 7 + apps/local/src/app.ts | 10 +- apps/local/src/db/executor-schema.ts | 2 + apps/local/src/oauth-loopback-api.test.ts | 363 ++++ apps/local/src/oauth-loopback.test.ts | 216 ++ apps/local/src/oauth-loopback.ts | 206 ++ packages/core/api/src/handlers/oauth.ts | 39 +- packages/core/api/src/oauth/api.ts | 12 + packages/core/api/src/server.ts | 3 + packages/core/api/src/services.ts | 37 +- packages/core/sdk/src/core-schema.ts | 9 + packages/core/sdk/src/core-tools.ts | 47 +- packages/core/sdk/src/index.ts | 9 + packages/core/sdk/src/oauth-client.ts | 103 + .../sdk/src/oauth-loopback-callback.test.ts | 363 ++++ packages/core/sdk/src/oauth-service.ts | 106 +- packages/core/sdk/src/shared.ts | 9 + packages/react/src/api/atoms.tsx | 15 + .../src/components/oauth-client-form.test.ts | 38 + .../src/components/oauth-client-form.tsx | 199 +- 27 files changed, 4526 insertions(+), 47 deletions(-) create mode 100644 .changeset/oauth-loopback-callback-port.md create mode 100644 apps/cloud/drizzle/0021_breezy_shadowcat.sql create mode 100644 apps/cloud/drizzle/meta/0021_snapshot.json create mode 100644 apps/local/drizzle/0007_flimsy_harry_osborn.sql create mode 100644 apps/local/drizzle/meta/0007_snapshot.json create mode 100644 apps/local/src/oauth-loopback-api.test.ts create mode 100644 apps/local/src/oauth-loopback.test.ts create mode 100644 apps/local/src/oauth-loopback.ts create mode 100644 packages/core/sdk/src/oauth-loopback-callback.test.ts diff --git a/.changeset/oauth-loopback-callback-port.md b/.changeset/oauth-loopback-callback-port.md new file mode 100644 index 0000000000..f8e72ae276 --- /dev/null +++ b/.changeset/oauth-loopback-callback-port.md @@ -0,0 +1,33 @@ +--- +"@executor-js/sdk": minor +"@executor-js/api": minor +"@executor-js/react": minor +"@executor-js/local": minor +--- + +Serve a per-app loopback OAuth callback, so providers without dynamic client +registration can be connected from a local Executor. + +Some providers only accept a `redirect_uri` that is already registered on their +own OAuth app — Slack's MCP server answers anything else with `redirect_uri did +not match any configured URIs` — and that app usually pins a loopback port this +Executor could not offer. A registered OAuth app can now declare a +`callbackPort` (and optionally a `callbackPath`, default `/callback`) in +"Register an OAuth app"; the host binds `http://127.0.0.1:` for the +flow and sends it as `redirect_uri` on both the authorization request and the +token exchange. The listener forwards the provider's callback to the existing +completion route and closes once the flow is done. A port already in use — by +another local client mid-sign-in, say — fails the connect with that reason +instead of stranding the user at the provider. + +Two behaviours worth knowing about: + +- Starting a flow for an app that declares a callback requires passing that + exact URI as `redirectUri` (a host reads it from `oauth.loopbackCallback` and + binds it first). A caller that never asked for it — the agent-facing + `oauth.start` tool, for one — is refused with an actionable message rather + than handed an authorization URL nothing is listening at. +- A listener bound for a single flow closes as soon as it has served the + callback. If a second flow claims the same URI, the listener is left to its + TTL instead, so the first completion cannot pull the callback out from under + the second. diff --git a/apps/cloud/drizzle/0021_breezy_shadowcat.sql b/apps/cloud/drizzle/0021_breezy_shadowcat.sql new file mode 100644 index 0000000000..7ec3674a04 --- /dev/null +++ b/apps/cloud/drizzle/0021_breezy_shadowcat.sql @@ -0,0 +1,2 @@ +ALTER TABLE "oauth_client" ADD COLUMN "callback_port" text;--> statement-breakpoint +ALTER TABLE "oauth_client" ADD COLUMN "callback_path" text; \ No newline at end of file diff --git a/apps/cloud/drizzle/meta/0021_snapshot.json b/apps/cloud/drizzle/meta/0021_snapshot.json new file mode 100644 index 0000000000..1f9e4d57f0 --- /dev/null +++ b/apps/cloud/drizzle/meta/0021_snapshot.json @@ -0,0 +1,1772 @@ +{ + "id": "b74e90d7-5ca5-49e4-b7d2-abb34d04b8bc", + "prevId": "88f2845b-be28-4ad3-92b2-2cac819478e5", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sign_in_at": { + "name": "last_sign_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_email_lower_idx": { + "name": "accounts_email_lower_idx", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.membership_tombstones": { + "name": "membership_tombstones", + "schema": "", + "columns": { + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "membership_tombstones_organization_id_idx": { + "name": "membership_tombstones_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "membership_tombstones_account_id_accounts_id_fk": { + "name": "membership_tombstones_account_id_accounts_id_fk", + "tableFrom": "membership_tombstones", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "membership_tombstones_organization_id_organizations_id_fk": { + "name": "membership_tombstones_organization_id_organizations_id_fk", + "tableFrom": "membership_tombstones", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_membership_id_unique": { + "name": "memberships_membership_id_unique", + "columns": [ + { + "expression": "membership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memberships_organization_id_idx": { + "name": "memberships_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backfilled_at": { + "name": "backfilled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workos_sync": { + "name": "workos_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "range_start": { + "name": "range_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "backfill_completed_at": { + "name": "backfill_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "drained_at": { + "name": "drained_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bindings": { + "name": "bindings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artifact_uidx": { + "name": "artifact_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health": { + "name": "last_health", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "health_check": { + "name": "health_check", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_port": { + "name": "callback_port", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "callback_path": { + "name": "callback_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject": { + "name": "subject", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "subject_uidx": { + "name": "subject_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index 73842e4d59..289ea9a1ca 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -148,6 +148,13 @@ "when": 1789575639971, "tag": "0020_workos_sync_drained_at", "breakpoints": true + }, + { + "idx": 21, + "version": "7", + "when": 1790710541746, + "tag": "0021_breezy_shadowcat", + "breakpoints": true } ] } diff --git a/apps/cloud/src/db/executor-schema.ts b/apps/cloud/src/db/executor-schema.ts index 0db709b884..fe3b0dcc04 100644 --- a/apps/cloud/src/db/executor-schema.ts +++ b/apps/cloud/src/db/executor-schema.ts @@ -106,6 +106,8 @@ export const oauth_client = pgTable( origin_integration: text("origin_integration"), origin_issuer: text("origin_issuer"), origin_redirect_uri: text("origin_redirect_uri"), + callback_port: text("callback_port"), + callback_path: text("callback_path"), created_at: timestamp("created_at").notNull(), row_id: varchar("row_id", { length: 255 }) .primaryKey() diff --git a/apps/local/drizzle/0007_flimsy_harry_osborn.sql b/apps/local/drizzle/0007_flimsy_harry_osborn.sql new file mode 100644 index 0000000000..7588a0ff14 --- /dev/null +++ b/apps/local/drizzle/0007_flimsy_harry_osborn.sql @@ -0,0 +1,2 @@ +ALTER TABLE `oauth_client` ADD `callback_port` text;--> statement-breakpoint +ALTER TABLE `oauth_client` ADD `callback_path` text; \ No newline at end of file diff --git a/apps/local/drizzle/meta/0007_snapshot.json b/apps/local/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000000..dcbef48439 --- /dev/null +++ b/apps/local/drizzle/meta/0007_snapshot.json @@ -0,0 +1,962 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "af3a3929-4209-478e-ace9-b0b2977a8721", + "prevId": "89a4fd1b-f0f6-4482-a991-0db78c859f76", + "tables": { + "blob": { + "name": "blob", + "columns": { + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": ["id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "connection": { + "name": "connection", + "columns": { + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_ids": { + "name": "item_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_write": { + "name": "credential_write", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "blob", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_state": { + "name": "provider_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": ["tenant", "owner", "subject", "integration", "name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "definition": { + "name": "definition", + "columns": { + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection": { + "name": "connection", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": ["tenant", "owner", "subject", "integration", "connection", "name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration": { + "name": "integration", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "can_remove": { + "name": "can_remove", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "can_refresh": { + "name": "can_refresh", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": ["tenant", "slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_client": { + "name": "oauth_client", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential_write": { + "name": "credential_write", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "callback_port": { + "name": "callback_port", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "callback_path": { + "name": "callback_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": ["tenant", "owner", "subject", "slug"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "oauth_session": { + "name": "oauth_session", + "columns": { + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "blob", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": ["tenant", "state"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "plugin_storage": { + "name": "plugin_storage", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collection": { + "name": "collection", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": ["tenant", "owner", "subject", "plugin_id", "collection", "key"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tool": { + "name": "tool", + "columns": { + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "connection": { + "name": "connection", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_schema": { + "name": "input_schema", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_schema": { + "name": "output_schema", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "annotations": { + "name": "annotations", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": ["tenant", "owner", "subject", "integration", "connection", "name"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tool_policy": { + "name": "tool_policy", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant": { + "name": "tenant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": ["tenant", "owner", "subject", "id"], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/apps/local/drizzle/meta/_journal.json b/apps/local/drizzle/meta/_journal.json index dbe786204c..97ee79bb50 100644 --- a/apps/local/drizzle/meta/_journal.json +++ b/apps/local/drizzle/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1788255902609, "tag": "0006_bored_landau", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1790710540896, + "tag": "0007_flimsy_harry_osborn", + "breakpoints": true } ] } diff --git a/apps/local/src/app.ts b/apps/local/src/app.ts index c565c14d4b..50c2b9d732 100644 --- a/apps/local/src/app.ts +++ b/apps/local/src/app.ts @@ -3,6 +3,7 @@ import { Layer } from "effect"; import { ArtifactUsageObserver, + OAuthLoopbackListener, composePluginApi, ExecutorApp, FixedExecutionProvider, @@ -15,6 +16,7 @@ import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; import { localAnalytics } from "./analytics"; import { getExecutorBundle, type LocalExecutor } from "./executor"; import { makeLocalIdentityLayer } from "./identity"; +import { makeOAuthLoopbackListener } from "./oauth-loopback"; import { ErrorCaptureLive } from "./observability"; // =========================================================================== @@ -89,7 +91,7 @@ export interface LocalApiHandler { * test seam where a test wants to bypass the boot graph. */ export const makeLocalApiHandler = async (token: string): Promise => { - const { executor, plugins } = await getExecutorBundle(); + const { executor, plugins, webBaseUrl } = await getExecutorBundle(); // Build the fixed-execution seam ONCE (one executor + one engine). The same // Layer is the `fixedExecution` seam declaration AND lives in `boot` so the @@ -136,6 +138,12 @@ export const makeLocalApiHandler = async (token: string): Promise localAnalytics.record(`artifact_${action}`, { via: "ui" }), ), + // Loopback callbacks for apps whose provider registration pins one (RFC + // 8252 §7.3). This host and the browser share a machine, which is the one + // condition that makes such a callback servable at all. Bound listeners + // live only as long as the flow that needs them (or their TTL), so there + // is nothing to release at shutdown. + Layer.succeed(OAuthLoopbackListener)(makeOAuthLoopbackListener(webBaseUrl)), ), }); diff --git a/apps/local/src/db/executor-schema.ts b/apps/local/src/db/executor-schema.ts index a7f6ceb48e..1c0ab479a0 100644 --- a/apps/local/src/db/executor-schema.ts +++ b/apps/local/src/db/executor-schema.ts @@ -80,6 +80,8 @@ export const oauth_client = sqliteTable( origin_integration: text("origin_integration"), origin_issuer: text("origin_issuer"), origin_redirect_uri: text("origin_redirect_uri"), + callback_port: text("callback_port"), + callback_path: text("callback_path"), created_at: integer("created_at").notNull(), row_id: text("row_id").primaryKey().notNull(), tenant: text("tenant").notNull(), diff --git a/apps/local/src/oauth-loopback-api.test.ts b/apps/local/src/oauth-loopback-api.test.ts new file mode 100644 index 0000000000..f4f8b12c0c --- /dev/null +++ b/apps/local/src/oauth-loopback-api.test.ts @@ -0,0 +1,363 @@ +// --------------------------------------------------------------------------- +// Local app × loopback OAuth callback — real HTTP, real sockets (v2) +// --------------------------------------------------------------------------- +// +// The whole chain is real: +// +// test → real loopback socket (127.0.0.1:) +// → LocalApi OAuthHandlers (`oauth.start` binds the URI first) +// → OAuthTestServer (metadata, /authorize → login, /token) +// +// It proves what a provider without dynamic client registration depends on: +// declaring a callback port makes `oauth.start` serve THAT exact URI and send it +// as `redirect_uri` on both the authorize request and the token exchange, the +// provider's redirect reaches the daemon's completion route, and a port someone +// else holds fails the request with the reason instead of stranding the user at +// the provider. +// --------------------------------------------------------------------------- + +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { randomBytes } from "node:crypto"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createServer } from "node:net"; + +import { HttpApi, HttpApiBuilder, HttpApiClient } from "effect/unstable/httpapi"; +import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; +import { Effect, Layer } from "effect"; + +import { addGroup, observabilityMiddleware } from "@executor-js/api"; +import { + CoreHandlers, + ExecutionEngineService, + ExecutorService, + OAuthLoopbackListener, + collectTables, +} from "@executor-js/api/server"; +import { createExecutionEngine } from "@executor-js/execution"; +import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, + Subject, + Tenant, + createExecutor, +} from "@executor-js/sdk"; +import { serveOAuthTestServer } from "@executor-js/sdk/testing"; +import { fileSecretsPlugin } from "@executor-js/plugin-file-secrets"; +import { mcpPlugin } from "@executor-js/plugin-mcp"; +import { McpExtensionService, McpGroup, McpHandlers } from "@executor-js/plugin-mcp/api"; + +import { ErrorCaptureLive } from "./observability"; +import { createSqliteFumaDb } from "./db/sqlite-fumadb"; +import { makeOAuthLoopbackListener, type LocalOAuthLoopbackListener } from "./oauth-loopback"; + +const TestApi = addGroup(McpGroup); +type TestApiShape = + typeof TestApi extends HttpApi.HttpApi + ? HttpApiClient.Client + : never; + +const TEST_BASE_URL = "http://local.test"; +/** The origin this harness's executor derives its OWN callback from — the local + * daemon's default. The loopback listener forwards here. */ +const DAEMON_ORIGIN = "http://localhost:4788"; + +const freePort = async (): Promise => + new Promise((resolve, reject) => { + const probe = createServer(); + probe.once("error", reject); + probe.listen(0, "127.0.0.1", () => { + const address = probe.address(); + if (address === null || typeof address === "string") { + reject(new Error("probe socket has no port")); + return; + } + const port = address.port; + probe.close(() => resolve(port)); + }); + }); + +interface Harness { + /** The in-process web handler, reached through this origin. */ + readonly fetch: typeof globalThis.fetch; + readonly registerRemoteServer: (input: { + readonly slug: string; + readonly endpoint: string; + }) => Effect.Effect; + readonly listener: LocalOAuthLoopbackListener; + readonly dispose: () => Promise; +} + +const startHarness = async (tmpDir: string): Promise => { + const plugins = [ + mcpPlugin({ dangerouslyAllowStdioMCP: false }), + fileSecretsPlugin({ directory: tmpDir }), + ] as const; + const sqlite = await createSqliteFumaDb({ + tables: collectTables(), + namespace: "executor_local_loopback_test", + path: join(tmpDir, "data.db"), + }); + + const executor = await Effect.runPromise( + createExecutor({ + tenant: Tenant.make(`test-${randomBytes(4).toString("hex")}`), + subject: Subject.make("local"), + db: sqlite.db, + plugins, + onElicitation: "accept-all", + oauthEndpointUrlPolicy: { allowHttp: true }, + redirectUri: `${DAEMON_ORIGIN}/api/oauth/callback`, + }), + ); + + const engine = createExecutionEngine({ + executor, + codeExecutor: makeQuickJsExecutor(), + }); + + const listener = makeOAuthLoopbackListener(DAEMON_ORIGIN); + const TestObservability = observabilityMiddleware(TestApi); + + const TestApiBase = HttpApiBuilder.layer(TestApi).pipe( + Layer.provide(CoreHandlers), + Layer.provide(McpHandlers), + Layer.provide(TestObservability), + Layer.provide(ErrorCaptureLive), + ); + + const pluginExtensions = Layer.succeed(McpExtensionService)(executor.mcp); + + const { handler: webHandler, dispose: disposeHandler } = HttpRouter.toWebHandler( + TestApiBase.pipe( + Layer.provideMerge(pluginExtensions), + Layer.provideMerge(Layer.succeed(ExecutorService)(executor)), + Layer.provideMerge(Layer.succeed(ExecutionEngineService)(engine)), + // The host capability the real local daemon provides in `app.ts`. + Layer.provideMerge(Layer.succeed(OAuthLoopbackListener)(listener)), + Layer.provideMerge(HttpServer.layerServices), + Layer.provideMerge(Layer.succeed(HttpRouter.RouterConfig)({ maxParamLength: 1000 })), + ), + ); + + return { + fetch: ((input: RequestInfo | URL, init?: RequestInit) => + webHandler( + input instanceof Request ? input : new Request(input, init), + )) as typeof globalThis.fetch, + listener, + registerRemoteServer: ({ slug, endpoint }) => + executor.mcp + .addServer({ + transport: "remote", + name: slug, + slug, + endpoint, + authenticationTemplate: [{ kind: "oauth2", slug: "oauth" }], + }) + .pipe(Effect.asVoid), + dispose: async () => { + listener.closeAll(); + await Effect.runPromise(Effect.ignore(Effect.tryPromise(() => disposeHandler()))); + await Effect.runPromise( + Effect.ignore(Effect.tryPromise(() => Effect.runPromise(executor.close()))), + ); + await sqlite.close(); + }, + }; +}; + +const tmpDirs: string[] = []; +const harnesses: Harness[] = []; + +const openHarness = async (): Promise => { + const tmpDir = mkdtempSync(join(tmpdir(), "executor-local-loopback-")); + tmpDirs.push(tmpDir); + const harness = await startHarness(tmpDir); + harnesses.push(harness); + return harness; +}; + +afterEach(async () => { + while (harnesses.length > 0) await harnesses.pop()?.dispose(); + while (tmpDirs.length > 0) { + const dir = tmpDirs.pop(); + if (dir !== undefined) rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("local oauth loopback callback (real API, real sockets)", () => { + it.effect( + "serves the declared port and completes the flow the provider redirects there", + () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* Effect.promise(() => openHarness()); + const oauth = yield* serveOAuthTestServer({ scopes: ["read"] }); + const callbackPort = yield* Effect.promise(() => freePort()); + const clientLayer = FetchHttpClient.layer.pipe( + Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(harness.fetch)), + ); + + const run = (body: (client: TestApiShape) => Effect.Effect) => + Effect.gen(function* () { + const client = yield* HttpApiClient.make(TestApi, { baseUrl: TEST_BASE_URL }); + return yield* body(client); + }).pipe(Effect.provide(clientLayer)) as Effect.Effect; + + yield* harness.registerRemoteServer({ + slug: "mcp_remote", + endpoint: oauth.mcpResourceUrl, + }); + + const slug = `slack-${randomBytes(4).toString("hex")}`; + yield* run((client) => + client.oauth.createClient({ + payload: { + owner: "org", + slug: OAuthClientSlug.make(slug), + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + // Public PKCE client whose provider app pins this exact URI: + // the shape the fixed-callback provider forces. + callbackPort, + }, + }), + ); + + const started = yield* run((client) => + client.oauth.start({ + payload: { + client: OAuthClientSlug.make(slug), + clientOwner: "org", + owner: "org", + name: ConnectionName.make("slack"), + integration: IntegrationSlug.make("mcp_remote"), + template: AuthTemplateSlug.make("oauth"), + // The UI's own origin is NOT what an app with a pinned callback + // may use; the declaration outranks it. + redirectUri: `${DAEMON_ORIGIN}/api/oauth/callback`, + }, + }), + ); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + expect(new URL(started.authorizationUrl).searchParams.get("redirect_uri")).toBe( + `http://127.0.0.1:${callbackPort}/callback`, + ); + + // Drive the authorization at the provider: it redirects the browser to + // the URI it has registered, which is the port the listener bound. + const callback = yield* oauth.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + expect( + callback.callbackUrl.startsWith(`http://127.0.0.1:${callbackPort}/callback?`), + ).toBe(true); + + // The provider's redirect, over a real socket, to the bound port. + const providerRedirect = yield* Effect.promise(() => + fetch(callback.callbackUrl, { redirect: "manual" }), + ); + expect(providerRedirect.status).toBe(302); + const forwarded = providerRedirect.headers.get("location") ?? ""; + expect(forwarded.startsWith(`${DAEMON_ORIGIN}/api/oauth/callback?`)).toBe(true); + + // …and the daemon's completion route mints the connection. + const forwardedPath = new URL(forwarded); + yield* Effect.promise(() => + harness.fetch(`${TEST_BASE_URL}/oauth/callback${forwardedPath.search}`), + ); + + const connections = yield* run((client) => client.connections.list({ query: {} })); + expect(connections.some((connection) => String(connection.name) === "slack")).toBe(true); + + // The token exchange sent the SAME redirect_uri the authorize request + // did — the server rejects the code otherwise, and the request is + // checked here so the guarantee is asserted, not inferred. + const tokenRequest = (yield* oauth.requests).find((request) => request.path === "/token"); + expect(new URLSearchParams(tokenRequest?.body ?? "").get("redirect_uri")).toBe( + `http://127.0.0.1:${callbackPort}/callback`, + ); + }), + ), + 30_000, + ); + + it.effect( + "fails the start with the reason when the declared port is already held", + () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* Effect.promise(() => openHarness()); + const oauth = yield* serveOAuthTestServer({ scopes: ["read"] }); + const callbackPort = yield* Effect.promise(() => freePort()); + const holder = Bun.serve({ + hostname: "127.0.0.1", + port: callbackPort, + fetch: () => new Response("held"), + }); + const clientLayer = FetchHttpClient.layer.pipe( + Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(harness.fetch)), + ); + + const run = (body: (client: TestApiShape) => Effect.Effect) => + Effect.gen(function* () { + const client = yield* HttpApiClient.make(TestApi, { baseUrl: TEST_BASE_URL }); + return yield* body(client); + }).pipe(Effect.provide(clientLayer)) as Effect.Effect; + + try { + yield* harness.registerRemoteServer({ + slug: "mcp_remote", + endpoint: oauth.mcpResourceUrl, + }); + + const slug = `slack-${randomBytes(4).toString("hex")}`; + yield* run((client) => + client.oauth.createClient({ + payload: { + owner: "org", + slug: OAuthClientSlug.make(slug), + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + callbackPort, + }, + }), + ); + + const failure = yield* Effect.flip( + run((client) => + client.oauth.start({ + payload: { + client: OAuthClientSlug.make(slug), + clientOwner: "org", + owner: "org", + name: ConnectionName.make("slack"), + integration: IntegrationSlug.make("mcp_remote"), + template: AuthTemplateSlug.make("oauth"), + }, + }), + ), + ); + expect(String((failure as { readonly message?: string }).message)).toContain( + `Port ${callbackPort} is already in use`, + ); + } finally { + holder.stop(true); + } + }), + ), + 30_000, + ); +}); diff --git a/apps/local/src/oauth-loopback.test.ts b/apps/local/src/oauth-loopback.test.ts new file mode 100644 index 0000000000..1bbdab4863 --- /dev/null +++ b/apps/local/src/oauth-loopback.test.ts @@ -0,0 +1,216 @@ +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; +import { createServer } from "node:net"; + +import { + makeOAuthLoopbackListener, + OAUTH_LOOPBACK_CLOSE_DELAY_MS, + type LocalOAuthLoopbackListener, +} from "./oauth-loopback"; + +// --------------------------------------------------------------------------- +// The local daemon's loopback callback listener, over real sockets. +// +// The provider redirects a real browser to `http://127.0.0.1:`; the +// listener hands that request to the daemon's own completion route, which owns +// completion and the completion page. These tests use real listeners and real +// HTTP requests — no stubs — because the thing under test IS the wire. +// --------------------------------------------------------------------------- + +const openListeners: LocalOAuthLoopbackListener[] = []; +const openServers: { stop: (closeActiveConnections?: boolean) => void }[] = []; + +afterEach(() => { + for (const listener of openListeners.splice(0)) listener.closeAll(); + for (const server of openServers.splice(0)) server.stop(true); +}); + +/** A genuinely free port: bind one, read it, release it. */ +const freePort = async (): Promise => + new Promise((resolve, reject) => { + const probe = createServer(); + probe.once("error", reject); + probe.listen(0, "127.0.0.1", () => { + const address = probe.address(); + if (address === null || typeof address === "string") { + reject(new Error("probe socket has no port")); + return; + } + const port = address.port; + probe.close(() => resolve(port)); + }); + }); + +const listening = (webBaseUrl: string): LocalOAuthLoopbackListener => { + const listener = makeOAuthLoopbackListener(webBaseUrl); + openListeners.push(listener); + return listener; +}; + +describe("local loopback OAuth callback listener", () => { + it("forwards the provider's callback to the daemon's completion route", async () => { + const daemonPort = await freePort(); + const callbackPort = await freePort(); + const seen: string[] = []; + openServers.push( + Bun.serve({ + hostname: "127.0.0.1", + port: daemonPort, + fetch: (request) => { + seen.push(new URL(request.url).pathname + new URL(request.url).search); + return new Response("completion", { + headers: { "content-type": "text/html" }, + }); + }, + }), + ); + + const webBaseUrl = `http://127.0.0.1:${daemonPort}`; + const listener = listening(webBaseUrl); + const callbackUrl = `http://127.0.0.1:${callbackPort}/callback`; + + await Effect.runPromise(listener.listen(callbackUrl)); + + // What the provider's redirect does, verbatim: GET the loopback URI with the + // code and the correlation state (plus a provider extra, which must survive). + const redirect = await fetch(`${callbackUrl}?code=abc123&state=xyz&domain=datadoghq.eu`, { + redirect: "manual", + }); + expect(redirect.status).toBe(302); + expect(redirect.headers.get("location")).toBe( + `${webBaseUrl}/api/oauth/callback?code=abc123&state=xyz&domain=datadoghq.eu`, + ); + + // And it really lands on the daemon route. + expect(await (await fetch(redirect.headers.get("location") ?? "")).text()).toContain( + "completion", + ); + expect(seen).toEqual(["/api/oauth/callback?code=abc123&state=xyz&domain=datadoghq.eu"]); + }); + + it("serves only the declared path", async () => { + const callbackPort = await freePort(); + const listener = listening(`http://127.0.0.1:${await freePort()}`); + await Effect.runPromise(listener.listen(`http://127.0.0.1:${callbackPort}/oauth/cb`)); + + const wrongPath = await fetch(`http://127.0.0.1:${callbackPort}/callback?code=a&state=b`, { + redirect: "manual", + }); + expect(wrongPath.status).toBe(404); + expect(await wrongPath.text()).toContain("/oauth/cb"); + + const rightPath = await fetch(`http://127.0.0.1:${callbackPort}/oauth/cb?code=a&state=b`, { + redirect: "manual", + }); + expect(rightPath.status).toBe(302); + }); + + it("reports a port another process already holds, naming the likely culprit", async () => { + const callbackPort = await freePort(); + openServers.push( + Bun.serve({ hostname: "127.0.0.1", port: callbackPort, fetch: () => new Response("taken") }), + ); + + const listener = listening(`http://127.0.0.1:${await freePort()}`); + const failure = await Effect.runPromise( + Effect.flip(listener.listen(`http://127.0.0.1:${callbackPort}/callback`)), + ); + expect(failure._tag).toBe("OAuthLoopbackListenError"); + expect(failure.message).toContain(`Port ${callbackPort} is already in use`); + expect(failure.message).toContain("Claude Code"); + }); + + it("reuses a listener already bound instead of contending with itself", async () => { + const callbackPort = await freePort(); + const listener = listening(`http://127.0.0.1:${await freePort()}`); + const url = `http://127.0.0.1:${callbackPort}/callback`; + + await Effect.runPromise(listener.listen(url)); + // A retry — the user reopens the connect dialog — must not fail on our own + // port, and must leave the callback served. + await Effect.runPromise(listener.listen(url)); + const redirect = await fetch(`${url}?code=a&state=b`, { redirect: "manual" }); + expect(redirect.status).toBe(302); + }); + + it("releases the port when the flow is over", async () => { + const callbackPort = await freePort(); + const listener = listening(`http://127.0.0.1:${await freePort()}`); + await Effect.runPromise(listener.listen(`http://127.0.0.1:${callbackPort}/callback`)); + expect( + ( + await fetch(`http://127.0.0.1:${callbackPort}/callback?code=a&state=b`, { + redirect: "manual", + }) + ).status, + ).toBe(302); + + listener.closeAll(); + // Free again: the daemon can bind it, and nothing else can. + const rebound = Bun.serve({ + hostname: "127.0.0.1", + port: callbackPort, + fetch: () => new Response("ok"), + }); + openServers.push(rebound); + expect(rebound.port).toBe(callbackPort); + }); + + it("keeps serving a URI two flows share after the first one completes", async () => { + const daemonPort = await freePort(); + const callbackPort = await freePort(); + openServers.push( + Bun.serve({ + hostname: "127.0.0.1", + port: daemonPort, + fetch: () => new Response("completion"), + }), + ); + const listener = listening(`http://127.0.0.1:${daemonPort}`); + const url = `http://127.0.0.1:${callbackPort}/callback`; + + // Two flows start through the same app before either finishes — two tabs + // connecting the same integration, which is what one declared URI means. + await Effect.runPromise(listener.listen(url)); + await Effect.runPromise(listener.listen(url)); + + const first = await fetch(`${url}?code=a&state=1`, { redirect: "manual" }); + expect(first.status).toBe(302); + + // The second user is still on the consent screen. If the first completion + // had closed the listener, their redirect would hit a refused connection + // after they finished signing in — the failure this guards. + await new Promise((resolve) => setTimeout(resolve, OAUTH_LOOPBACK_CLOSE_DELAY_MS + 300)); + const second = await fetch(`${url}?code=b&state=2`, { redirect: "manual" }); + expect(second.status).toBe(302); + expect(second.headers.get("location")).toContain("/api/oauth/callback?code=b&state=2"); + }); + + it("closes behind a callback it served for a single flow", async () => { + const callbackPort = await freePort(); + const listener = listening(`http://127.0.0.1:${await freePort()}`); + const url = `http://127.0.0.1:${callbackPort}/callback`; + await Effect.runPromise(listener.listen(url)); + expect((await fetch(`${url}?code=a&state=b`, { redirect: "manual" })).status).toBe(302); + + // One flow, one callback: the port goes back as soon as the browser has its + // redirect, rather than waiting out the TTL. + await new Promise((resolve) => setTimeout(resolve, OAUTH_LOOPBACK_CLOSE_DELAY_MS + 300)); + const rebound = Bun.serve({ + hostname: "127.0.0.1", + port: callbackPort, + fetch: () => new Response("ok"), + }); + openServers.push(rebound); + expect(rebound.port).toBe(callbackPort); + }); + + it("refuses a callback when the browser is not on this machine", async () => { + const listener = listening("https://executor.example.com"); + const failure = await Effect.runPromise( + Effect.flip(listener.listen(`http://127.0.0.1:${await freePort()}/callback`)), + ); + expect(failure._tag).toBe("OAuthLoopbackListenError"); + expect(failure.message).toContain("same machine as the browser"); + }); +}); diff --git a/apps/local/src/oauth-loopback.ts b/apps/local/src/oauth-loopback.ts new file mode 100644 index 0000000000..d2779d8eb5 --- /dev/null +++ b/apps/local/src/oauth-loopback.ts @@ -0,0 +1,206 @@ +import { Effect } from "effect"; + +import { OAuthLoopbackListenError, type OAuthLoopbackListenerShape } from "@executor-js/api/server"; + +// --------------------------------------------------------------------------- +// Loopback OAuth callbacks served by the local daemon (RFC 8252 §7.3). +// +// A provider whose OAuth app was registered by someone else — Slack's MCP +// server, for one — only accepts a redirect URI that is already on that app. +// The user therefore registers ONE loopback URI there and the app declares it; +// this host serves it, so the provider's redirect lands on the daemon instead +// of on a URL nobody answers. +// +// The listener is deliberately dumb: it forwards the provider's query string to +// the daemon's own `/api/oauth/callback`, which already owns completion, the +// popup handoff, and the completion page. There is exactly one implementation of +// "what happens when an authorization code arrives", and it is that route. +// +// It binds `127.0.0.1` on the port the app declared — never `localhost`, and +// never a port of our choosing: the provider compares the redirect URI as a +// string, so anything else is a different URI and is rejected. +// --------------------------------------------------------------------------- + +/** Where the provider's redirect is forwarded: the daemon's own completion + * route. Its path is fixed (`packages/core/api/src/oauth/api.ts`). */ +const DAEMON_CALLBACK_PATH = "/api/oauth/callback"; + +/** How long a bound callback keeps listening. Flows are user-paced — consent + * screens, MFA, an account picker — so this only exists to bound the listener, + * not to time the user out. A retry inside the window reuses the listener. */ +export const OAUTH_LOOPBACK_TTL_MS = 10 * 60 * 1000; + +/** Grace period between handing the browser its redirect and closing the + * socket, so the response is on the wire before the listener goes away. */ +export const OAUTH_LOOPBACK_CLOSE_DELAY_MS = 1000; + +const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]); + +const isLoopbackHostname = (hostname: string): boolean => + LOOPBACK_HOSTNAMES.has(hostname.trim().toLowerCase()); + +export interface LocalOAuthLoopbackListener extends OAuthLoopbackListenerShape { + /** Stop every bound callback. For shutdown and for tests, which must not leak + * a listening socket between cases. Idempotent. */ + readonly closeAll: () => void; +} + +interface BoundCallback { + /** Bumped every time the callback is handed to a new flow, so a pending + * "close after serving" from the previous flow cannot tear down a listener a + * retry has already reclaimed. */ + generation: number; + timer: ReturnType; + readonly server: { stop: (closeActiveConnections?: boolean) => void }; +} + +const addressInUse = (cause: unknown): boolean => { + if (cause instanceof Error) { + const code = (cause as { readonly code?: unknown }).code; + return ( + code === "EADDRINUSE" || + /EADDRINUSE|address already in use/i.test(cause.message) || + /address already in use/i.test(cause.name) + ); + } + return /EADDRINUSE|address already in use/i.test(String(cause)); +}; + +/** The page a browser sees when it reaches a loopback callback URL of a path we + * are not serving. Only the declared path is handled, so this is what a + * mistyped or stale redirect URI lands on. */ +const notFoundHtml = (declaredPath: string): string => + [ + 'Executor', + '', + '

Nothing is registered here

', + `

This Executor is only serving ${declaredPath} on this port for the current sign-in.

`, + "

Start the sign-in again from Executor and use the callback URL the app shows you.

", + "", + ].join(""); + +/** + * The listener a local daemon provides to `OAuthLoopbackListener`. + * + * `webBaseUrl` is the daemon's own origin — the same value its OAuth callback is + * derived from — and the provider's redirect is forwarded there. A daemon served + * from a non-loopback origin refuses to bind: its browser is on another machine, + * so a loopback URI on the server would never receive the callback. + */ +export const makeOAuthLoopbackListener = (webBaseUrl: string): LocalOAuthLoopbackListener => { + const bound = new Map(); + + const close = (url: string): void => { + const entry = bound.get(url); + if (entry === undefined) return; + bound.delete(url); + clearTimeout(entry.timer); + entry.server.stop(true); + }; + + const closeAll = (): void => { + for (const url of [...bound.keys()]) close(url); + }; + + /** Park the listener's TTL, replacing whatever was armed before. */ + const armTtl = (url: string, entry: BoundCallback): void => { + clearTimeout(entry.timer); + const timer = setTimeout(() => close(url), OAUTH_LOOPBACK_TTL_MS); + // A pending sign-in must not keep the daemon alive on its own. + timer.unref?.(); + entry.timer = timer; + }; + + const listen = (url: string): Effect.Effect => + Effect.suspend(() => { + const existing = bound.get(url); + // Already serving this exact callback: a retry — or a second flow for the + // same app — reclaims it rather than fighting itself for the port. From + // here the listener is SHARED, so the first completion no longer closes it + // (see the fetch handler) and its TTL is what bounds it. + if (existing !== undefined) { + existing.generation += 1; + armTtl(url, existing); + return Effect.void; + } + + const target = new URL(url); + const port = Number(target.port); + const path = target.pathname; + + if (target.port === "") { + return Effect.fail( + new OAuthLoopbackListenError({ + url, + message: `A loopback callback needs an explicit port: ${url}`, + }), + ); + } + + if (!isLoopbackHostname(new URL(webBaseUrl).hostname)) { + return Effect.fail( + new OAuthLoopbackListenError({ + url, + message: + `A loopback callback only works when Executor runs on the same machine as the ` + + `browser, but this Executor is served at ${webBaseUrl}. Use the callback URL ` + + `shown in the app instead.`, + }), + ); + } + + return Effect.try({ + try: () => { + const server = Bun.serve({ + hostname: "127.0.0.1", + port, + fetch: (request: Request) => { + const incoming = new URL(request.url); + if (incoming.pathname !== path) { + return new Response(notFoundHtml(path), { + status: 404, + headers: { "content-type": "text/html; charset=utf-8" }, + }); + } + const entry = bound.get(url); + // The whole query travels verbatim: `code`/`state` for the exchange, + // and provider extras (Datadog's `domain`/`site`) that the completion + // route already knows how to read. + const destination = new URL(DAEMON_CALLBACK_PATH, webBaseUrl); + destination.search = incoming.search; + if (entry !== undefined) { + // Close behind the response: the browser already has its redirect, + // and a one-shot loopback callback has no reason to keep the port. + // ONLY while the listener was never handed to a second flow: two + // flows can share one declared URI (two tabs connecting the same + // app), and if the first completion tore the listener down, the + // second user would finish consenting and hit a refused connection. + // A shared listener is left to its TTL instead. Re-checked when the + // timer fires, so a flow that claims the URI in the meantime wins. + setTimeout(() => { + if (bound.get(url)?.generation === 0) close(url); + }, OAUTH_LOOPBACK_CLOSE_DELAY_MS); + } + return Response.redirect(destination.toString(), 302); + }, + }); + const entry: BoundCallback = { generation: 0, timer: setTimeout(() => {}, 0), server }; + armTtl(url, entry); + bound.set(url, entry); + }, + catch: (cause) => + new OAuthLoopbackListenError({ + url, + message: addressInUse(cause) + ? `Port ${port} is already in use, so Executor cannot serve ${url}. ` + + `Another client (Claude Code, Cursor, …) may be mid-sign-in on that port. ` + + `Finish or close that flow, or register a different callback port for this app.` + : `Could not serve the loopback callback ${url}: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + }), + }); + }); + + return { listen, closeAll }; +}; diff --git a/packages/core/api/src/handlers/oauth.ts b/packages/core/api/src/handlers/oauth.ts index eb4b1f939b..13f1ff43bb 100644 --- a/packages/core/api/src/handlers/oauth.ts +++ b/packages/core/api/src/handlers/oauth.ts @@ -18,13 +18,14 @@ import { OAuthSessionNotFoundError, OAuthStartError, OAuthState, + oauthLoopbackCallbackUrl, type Connection, type ConnectResult, } from "@executor-js/sdk"; import { ExecutorApi } from "../api"; import { capture } from "../observability"; -import { ExecutorService } from "../services"; +import { ExecutorService, OAuthLoopbackListener } from "../services"; const OAUTH_POPUP_CHANNEL = OAUTH_POPUP_MESSAGE_TYPE; @@ -106,6 +107,8 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler tokenEndpointAuthMethod: payload.tokenEndpointAuthMethod, resource: payload.resource ?? null, origin: { kind: "manual", integration: payload.originIntegration ?? null }, + callbackPort: payload.callbackPort ?? null, + callbackPath: payload.callbackPath ?? null, }); return { client }; }), @@ -154,6 +157,38 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler capture( Effect.gen(function* () { const executor = yield* ExecutorService; + // An app whose provider registration pins a loopback callback needs + // THIS host listening on that exact URI before the flow starts: + // `start` sends the declared callback verbatim, so a flow whose + // listener is missing strands the user at the provider. Binding first + // also means a port someone else holds fails here, with the reason, + // instead of after a trip through the consent screen. + const loopback = yield* executor.oauth.loopbackCallback({ + client: payload.client, + clientOwner: payload.clientOwner, + }); + // For a declared callback this IS the flow's redirect URI — the same + // string the listener serves and the value `start` insists on, so the + // two cannot drift. + let callbackUrl: string | null = null; + if (loopback !== null) { + callbackUrl = oauthLoopbackCallbackUrl(loopback); + const listener = yield* Effect.service(OAuthLoopbackListener); + if (listener === null) { + return yield* new OAuthStartError({ + message: + `The OAuth app ${String(payload.client)} requires the loopback callback ` + + `${callbackUrl}, but this Executor cannot serve one: a loopback callback only ` + + `works when Executor runs on the same machine as the browser. Use the callback ` + + `URL the app shows you instead.`, + }); + } + yield* listener + .listen(callbackUrl) + .pipe( + Effect.mapError((failure) => new OAuthStartError({ message: failure.message })), + ); + } const result = yield* executor.oauth.start({ client: payload.client, clientOwner: payload.clientOwner, @@ -163,7 +198,7 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler template: payload.template, identityLabel: payload.identityLabel, newConnection: payload.newConnection, - redirectUri: payload.redirectUri, + redirectUri: callbackUrl ?? payload.redirectUri, // Enterprise-managed authorization inputs. Ignored by every other // grant, and REQUIRED by `id_jag` — the identity assertion is held // by the caller, never by the server. diff --git a/packages/core/api/src/oauth/api.ts b/packages/core/api/src/oauth/api.ts index 5d0eee3d41..aa470aa4a2 100644 --- a/packages/core/api/src/oauth/api.ts +++ b/packages/core/api/src/oauth/api.ts @@ -73,6 +73,13 @@ const CreateClientPayload = Schema.Struct({ /** Integration whose connect dialog registered this manual app. Recorded so * the picker matches it to this integration by intent, not root domain. */ originIntegration: Schema.optional(Schema.NullOr(IntegrationSlug)), + /** Loopback callback this app's provider registration pins, when it is not + * this Executor's own callback (RFC 8252 §7.3). The host serves this exact + * URI and sends it as `redirect_uri`. */ + callbackPort: Schema.optional(Schema.NullOr(Schema.Number)), + /** Path of that callback; omitted means `/callback`. Only meaningful with + * `callbackPort`. */ + callbackPath: Schema.optional(Schema.NullOr(Schema.String)), }); const CreateClientResponse = Schema.Struct({ @@ -120,6 +127,11 @@ const OAuthClientSummaryResponse = Schema.Struct({ resource: Schema.optional(Schema.NullOr(Schema.String)), clientId: Schema.String, tokenEndpointAuthMethod: Schema.optional(TokenEndpointAuthMethodSchema), + /** Loopback callback the app declares, when the provider registration pins + * one instead of accepting this Executor's own callback. Not a secret: it is + * the URI the user registered with the provider. */ + callbackPort: Schema.optional(Schema.NullOr(Schema.Number)), + callbackPath: Schema.optional(Schema.NullOr(Schema.String)), origin: Schema.Union([ Schema.Struct({ kind: Schema.Literal("manual") }), Schema.Struct({ diff --git a/packages/core/api/src/server.ts b/packages/core/api/src/server.ts index bba228e10f..c2ccf53c17 100644 --- a/packages/core/api/src/server.ts +++ b/packages/core/api/src/server.ts @@ -2,7 +2,10 @@ export { ArtifactUsageObserver, ExecutorService, ExecutionEngineService, + OAuthLoopbackListenError, + OAuthLoopbackListener, type ArtifactUsageAction, + type OAuthLoopbackListenerShape, } from "./services"; export { CoreHandlers, diff --git a/packages/core/api/src/services.ts b/packages/core/api/src/services.ts index 248b185237..d864ed2d30 100644 --- a/packages/core/api/src/services.ts +++ b/packages/core/api/src/services.ts @@ -1,4 +1,4 @@ -import { Context, type Effect } from "effect"; +import { Context, Data, type Effect } from "effect"; import type * as Cause from "effect/Cause"; import type { Executor } from "@executor-js/sdk"; import type { ExecutionEngine } from "@executor-js/execution"; @@ -24,6 +24,41 @@ export const ArtifactUsageObserver = Context.Reference< ((action: ArtifactUsageAction) => Effect.Effect) | null >("@executor-js/api/ArtifactUsageObserver", { defaultValue: () => null }); +/** A host could not serve the loopback callback an OAuth app declares. */ +export class OAuthLoopbackListenError extends Data.TaggedError("OAuthLoopbackListenError")<{ + /** The callback URL that could not be served. */ + readonly url: string; + /** User-facing reason. The connect dialog renders it verbatim. */ + readonly message: string; +}> {} + +/** What a host must implement to serve a declared loopback callback. */ +export interface OAuthLoopbackListenerShape { + /** Serve `url` — always `http://127.0.0.1:` — so the provider's + * redirect reaches this Executor. Idempotent for the same URL within its + * TTL, since retrying a flow must reuse the listener already bound rather + * than fail on its own port. */ + readonly listen: (url: string) => Effect.Effect; +} + +/** + * Optional host capability: serve the loopback callback a registered OAuth app + * declares (RFC 8252 §7.3). + * + * A provider that does not support dynamic client registration only accepts a + * redirect URI registered on its own OAuth app, and Executor can only use that + * URI if something is listening there. Only a host whose browser shares the + * machine with the server can serve one — the CLI and the desktop app — so this + * is a `Context.Reference` (default null) rather than a required service: cloud, + * remote self-host, and every test compose unchanged, and a flow that NEEDS the + * callback fails with an actionable message instead of sending a redirect URI + * that nothing answers. + */ +export const OAuthLoopbackListener = Context.Reference( + "@executor-js/api/OAuthLoopbackListener", + { defaultValue: () => null }, +); + // Error channel widened to `Cause.YieldableError` so callers that plug // in a runtime-specific tagged error (e.g. // `ExecutionEngine`) assign structurally. diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index 8014584695..fdf854d3da 100644 --- a/packages/core/sdk/src/core-schema.ts +++ b/packages/core/sdk/src/core-schema.ts @@ -299,6 +299,15 @@ export const coreTables = defineTables({ // since re-registering every legacy client on upgrade would churn // providers for the majority whose callback never changed. origin_redirect_uri: nullableTextColumn("origin_redirect_uri"), + // The loopback callback THIS app's provider registration requires, when it + // is not the executor's own `/api/oauth/callback` (RFC 8252 §7.3). Set on + // apps for providers that do not support dynamic client registration and + // only accept a redirect registered on their own app; the host binds this + // exact URI and sends it as `redirect_uri`. Text like every other column + // here — it is registration metadata, not arithmetic — and parsed on read. + // Null (both columns) means the app uses the host's callback. + callback_port: nullableTextColumn("callback_port"), + callback_path: nullableTextColumn("callback_path"), created_at: dateColumn("created_at"), }, ["tenant", "owner", "subject", "slug"], diff --git a/packages/core/sdk/src/core-tools.ts b/packages/core/sdk/src/core-tools.ts index 461e04d8a9..184639988d 100644 --- a/packages/core/sdk/src/core-tools.ts +++ b/packages/core/sdk/src/core-tools.ts @@ -22,6 +22,7 @@ import { } from "./ids"; import { definePlugin, tool, type StaticToolSchema } from "./plugin"; import { HealthCheckResult, isToolSyncHealth } from "./health-check"; +import { OAuthStartError, oauthLoopbackCallbackUrl } from "./oauth-client"; import { ToolPolicyActionSchema } from "./policies"; import type { Tool } from "./tool"; import { ToolResult } from "./tool-result"; @@ -946,8 +947,25 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { // but one gate on the whole tool covers the silent path cleanly. annotations: { requiresApproval: true }, execute: (input: typeof OAuthStartInput.Type, { ctx }) => - Effect.map( - ctx.oauth.start({ + Effect.gen(function* () { + // An app whose provider registration pins a loopback callback needs + // a listener on THIS machine, and only the host that owns the socket + // can serve one — not this tool, and not an agent. Refuse instead of + // handing back an authorization URL that would strand the user at + // the provider with nothing listening at the registered redirect. + const loopback = yield* ctx.oauth.loopbackCallback({ + client: OAuthClientSlug.make(input.client), + clientOwner: input.clientOwner as Owner, + }); + if (loopback !== null) { + return yield* new OAuthStartError({ + message: + `This integration's OAuth app requires the loopback callback ` + + `${oauthLoopbackCallbackUrl(loopback)}, which only the local Executor app can ` + + `serve. Connect it from the Executor app instead of starting the flow here.`, + }); + } + const result = yield* ctx.oauth.start({ client: OAuthClientSlug.make(input.client), clientOwner: input.clientOwner as Owner, owner: input.owner as Owner, @@ -956,19 +974,18 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { template: AuthTemplateSlug.make(input.template), identityLabel: input.identityLabel, redirectUri: input.redirectUri, - }), - (result) => - result.status === "connected" - ? { - status: "connected" as const, - connection: connectionToOutput(result.connection), - } - : { - status: "redirect" as const, - authorizationUrl: result.authorizationUrl, - state: String(result.state), - }, - ), + }); + return result.status === "connected" + ? { + status: "connected" as const, + connection: connectionToOutput(result.connection), + } + : { + status: "redirect" as const, + authorizationUrl: result.authorizationUrl, + state: String(result.state), + }; + }), }), tool({ name: "oauth.cancel", diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 627d3de1de..33f0d8c12b 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -328,7 +328,16 @@ export { type OAuthAuthentication, type OAuthClient, type OAuthClientSummary, + type OAuthLoopbackCallback, type CreateOAuthClientInput, + DEFAULT_OAUTH_LOOPBACK_CALLBACK_PATH, + MAX_OAUTH_LOOPBACK_CALLBACK_PORT, + MIN_OAUTH_LOOPBACK_CALLBACK_PORT, + OAUTH_LOOPBACK_CALLBACK_HOST, + isOAuthLoopbackCallbackPort, + normalizeOAuthLoopbackCallbackPath, + oauthClientLoopbackCallback, + oauthLoopbackCallbackUrl, type RegisterDynamicClientInput, type ConnectResult, type OAuthStartInput, diff --git a/packages/core/sdk/src/oauth-client.ts b/packages/core/sdk/src/oauth-client.ts index 8778205014..83a5c1095f 100644 --- a/packages/core/sdk/src/oauth-client.ts +++ b/packages/core/sdk/src/oauth-client.ts @@ -139,8 +139,94 @@ export interface OAuthClient { /** RFC 8707 Resource Indicator (MCP). Carried so the refresh request can keep * the re-minted token bound to the same resource. Null/omitted otherwise. */ readonly resource?: string | null; + /** Port of the loopback callback THIS app's provider registration requires, + * when it is not the executor's own callback (RFC 8252 §7.3). A provider that + * does not support dynamic client registration pins its redirect URI on a + * pre-existing app, so the flow must send that exact URI and something must + * be listening there. Null/omitted means the host's own callback. */ + readonly callbackPort?: number | null; + /** Path of the declared loopback callback. Only meaningful with + * `callbackPort`; null/omitted uses {@link DEFAULT_OAUTH_LOOPBACK_CALLBACK_PATH}. */ + readonly callbackPath?: string | null; } +// --------------------------------------------------------------------------- +// Loopback callbacks (RFC 8252 §7.3). +// +// Some providers only accept a callback that was registered on their own OAuth +// app: Slack's MCP server, for one, has no registration endpoint and answers an +// unregistered redirect with "redirect_uri did not match any configured URIs". +// The user can therefore register ONE loopback URI on that app and have the +// client send it, which means the host must bind that URI itself. +// +// The host is spelled `127.0.0.1`, never `localhost`: authorization servers +// compare redirect URIs as strings, and the two spellings are different URIs. +// --------------------------------------------------------------------------- + +/** Literal host every declared loopback callback uses. */ +export const OAUTH_LOOPBACK_CALLBACK_HOST = "127.0.0.1"; + +/** Path a declared callback falls back to when the app names only a port. */ +export const DEFAULT_OAUTH_LOOPBACK_CALLBACK_PATH = "/callback"; + +/** Lowest port a declared callback may use — below 1024 is privileged, and a + * desktop/CLI host does not run as root. */ +export const MIN_OAUTH_LOOPBACK_CALLBACK_PORT = 1024; + +/** Highest port a declared callback may use (16-bit). */ +export const MAX_OAUTH_LOOPBACK_CALLBACK_PORT = 65535; + +/** A loopback callback a registered app declares: the exact URI its provider + * registration allows. */ +export interface OAuthLoopbackCallback { + readonly port: number; + readonly path: string; +} + +export const isOAuthLoopbackCallbackPort = (port: number): boolean => + Number.isInteger(port) && + port >= MIN_OAUTH_LOOPBACK_CALLBACK_PORT && + port <= MAX_OAUTH_LOOPBACK_CALLBACK_PORT; + +/** Normalize a declared callback path, or null when it is not a plain absolute + * path. A callback is compared character-for-character by the provider, so a + * path that would round-trip differently (query, fragment, space, percent + * escapes) is rejected instead of quietly sent as something else. */ +export const normalizeOAuthLoopbackCallbackPath = ( + path: string | null | undefined, +): string | null => { + const candidate = path == null ? "" : path.trim(); + if (candidate.length === 0) return DEFAULT_OAUTH_LOOPBACK_CALLBACK_PATH; + if (!candidate.startsWith("/")) return null; + try { + const parsed = new URL(`http://${OAUTH_LOOPBACK_CALLBACK_HOST}${candidate}`); + return parsed.pathname === candidate && parsed.search === "" && parsed.hash === "" + ? candidate + : null; + } catch { + return null; + } +}; + +/** The loopback callback an app declares, or null when it declares none. A path + * without a port is not a callback: the port is the part the provider pinned. + * An unusable pair (port out of range, malformed path) is also null, so a + * corrupt row degrades to "no declared callback" rather than a broken URL. */ +export const oauthClientLoopbackCallback = (client: { + readonly callbackPort?: number | null; + readonly callbackPath?: string | null; +}): OAuthLoopbackCallback | null => { + const port = client.callbackPort ?? null; + if (port === null || !isOAuthLoopbackCallbackPort(port)) return null; + const path = normalizeOAuthLoopbackCallbackPath(client.callbackPath); + return path === null ? null : { port, path }; +}; + +/** The exact URI sent to the provider as `redirect_uri` for a declared loopback + * callback. The listener binds this, not a re-derived equivalent. */ +export const oauthLoopbackCallbackUrl = (callback: OAuthLoopbackCallback): string => + `http://${OAUTH_LOOPBACK_CALLBACK_HOST}:${callback.port}${callback.path}`; + export type OAuthClientOrigin = | { readonly kind: "manual"; @@ -282,6 +368,11 @@ export type CreateOAuthClientInput = OAuthClient & { * reject an authorize request whose redirect_uri differs from the * registration). Ignored for manual clients. */ readonly originRedirectUri?: string | null; + /** Loopback callback to persist with the app (see {@link OAuthClient}). + * `undefined`/null stores none. Rejected when the port is outside the + * unprivileged range or the path is not a plain absolute path. */ + readonly callbackPort?: number | null; + readonly callbackPath?: string | null; }; /** Metadata-only projection of a registered client for listing in the UI. @@ -299,6 +390,10 @@ export interface OAuthClientSummary { /** Omitted for legacy rows, which use client_secret_post. */ readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod; readonly origin: OAuthClientOrigin; + /** Declared loopback callback, when this app's provider registration pins one + * instead of accepting the host's own callback. See {@link OAuthClient}. */ + readonly callbackPort?: number | null; + readonly callbackPath?: string | null; } /** Flow-aware result of `oauth.start` — the status says what's next. */ @@ -528,6 +623,14 @@ export interface OAuthService { /** All registered clients visible to the caller (their org's shared clients + * their own user clients), as metadata-only summaries — never the secret. */ readonly listClients: () => Effect.Effect; + /** The exact loopback callback a registered app declares, or null when it + * declares none. A host reads this BEFORE `start` to bind that URI, because + * `start` sends the declared callback verbatim — a flow whose listener is not + * already listening there fails at the provider. */ + readonly loopbackCallback: (input: { + readonly client: OAuthClientSlug; + readonly clientOwner: Owner; + }) => Effect.Effect; /** Permanently remove a registered OAuth app, keyed by (owner, slug). The * owner policy on `oauth_client` prevents removing another subject's user app. * Idempotent: removing an already-gone app succeeds. Connections that diff --git a/packages/core/sdk/src/oauth-loopback-callback.test.ts b/packages/core/sdk/src/oauth-loopback-callback.test.ts new file mode 100644 index 0000000000..7bedcecd44 --- /dev/null +++ b/packages/core/sdk/src/oauth-loopback-callback.test.ts @@ -0,0 +1,363 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Predicate } from "effect"; + +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, + ToolName, +} from "./ids"; +import { + DEFAULT_OAUTH_LOOPBACK_CALLBACK_PATH, + OAUTH_LOOPBACK_CALLBACK_HOST, + isOAuthLoopbackCallbackPort, + normalizeOAuthLoopbackCallbackPath, + oauthClientLoopbackCallback, + oauthLoopbackCallbackUrl, +} from "./oauth-client"; +import { definePlugin } from "./plugin"; +import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config"; +import { serveOAuthTestServer } from "./testing/oauth-test-server"; + +// --------------------------------------------------------------------------- +// A loopback callback a registered OAuth app declares (RFC 8252 §7.3). +// +// Providers without dynamic client registration only accept a redirect URI that +// is already on THEIR app — usually a loopback URI on a port the user cannot +// change. These tests cover the three things that must hold for that to work: +// the declared URI is what the authorize request AND the token exchange send +// (the AS compares them), the app stores and reports the declaration, and a +// declaration the provider could never have registered is refused up front +// rather than sent and rejected mid-flow. +// --------------------------------------------------------------------------- + +const INTEG = IntegrationSlug.make("acme"); +const TEMPLATE = AuthTemplateSlug.make("oauth"); +const CLIENT = OAuthClientSlug.make("acme-byo"); + +const oauthPlugin = definePlugin(() => ({ + id: "acme" as const, + storage: () => ({}), + resolveTools: () => + Effect.succeed({ + tools: [{ name: ToolName.make("whoami"), description: "whoami" }], + }), + describeAuthMethods: () => [ + { + id: "oauth", + label: "OAuth2", + kind: "oauth" as const, + template: String(TEMPLATE), + oauth: { scopes: ["read"] }, + }, + ], + invokeTool: () => Effect.succeed({ ok: true }), + checkHealth: () => Effect.succeed({ status: "healthy" as const, checkedAt: Date.now() }), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ + slug: INTEG, + description: "Acme", + config: { scopes: ["read"] }, + }), + }), +}))(); + +const plugins = [memoryCredentialsPlugin(), oauthPlugin] as const; + +describe("loopback callback declaration", () => { + it("spells the host 127.0.0.1 and defaults the path", () => { + // `localhost` and `127.0.0.1` are different URIs to an authorization server, + // and the provider app is registered with one of them exactly. + expect(OAUTH_LOOPBACK_CALLBACK_HOST).toBe("127.0.0.1"); + expect( + oauthLoopbackCallbackUrl({ port: 3118, path: DEFAULT_OAUTH_LOOPBACK_CALLBACK_PATH }), + ).toBe("http://127.0.0.1:3118/callback"); + }); + + it("normalizes a path, defaulting an absent one and refusing anything else", () => { + expect(normalizeOAuthLoopbackCallbackPath(undefined)).toBe("/callback"); + expect(normalizeOAuthLoopbackCallbackPath(null)).toBe("/callback"); + expect(normalizeOAuthLoopbackCallbackPath("")).toBe("/callback"); + expect(normalizeOAuthLoopbackCallbackPath("/oauth/cb")).toBe("/oauth/cb"); + // Round-trip failures: the URI we send must be the one the user registered. + expect(normalizeOAuthLoopbackCallbackPath("callback")).toBeNull(); + expect(normalizeOAuthLoopbackCallbackPath("/callback?x=1")).toBeNull(); + expect(normalizeOAuthLoopbackCallbackPath("/callback#fragment")).toBeNull(); + expect(normalizeOAuthLoopbackCallbackPath("/call back")).toBeNull(); + }); + + it("accepts only unprivileged ports", () => { + expect(isOAuthLoopbackCallbackPort(1024)).toBe(true); + expect(isOAuthLoopbackCallbackPort(65535)).toBe(true); + expect(isOAuthLoopbackCallbackPort(80)).toBe(false); + expect(isOAuthLoopbackCallbackPort(65536)).toBe(false); + expect(isOAuthLoopbackCallbackPort(3118.5)).toBe(false); + expect(isOAuthLoopbackCallbackPort(Number.NaN)).toBe(false); + }); + + it("derives a callback only from a port, and degrades a corrupt pair to none", () => { + expect(oauthClientLoopbackCallback({})).toBeNull(); + // A path without the port the provider pinned declares nothing. + expect(oauthClientLoopbackCallback({ callbackPath: "/callback" })).toBeNull(); + expect(oauthClientLoopbackCallback({ callbackPort: 3118 })).toEqual({ + port: 3118, + path: "/callback", + }); + expect(oauthClientLoopbackCallback({ callbackPort: 3118, callbackPath: "/oauth/cb" })).toEqual({ + port: 3118, + path: "/oauth/cb", + }); + // A corrupt row must not make the app unusable: no callback is the + // pre-existing behaviour for every app. + expect(oauthClientLoopbackCallback({ callbackPort: 80 })).toBeNull(); + expect(oauthClientLoopbackCallback({ callbackPort: 3118, callbackPath: "nope" })).toBeNull(); + }); +}); + +describe("oauth loopback callback", () => { + it.effect("stores the declaration, reports it, and sends it on both legs of the flow", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + + const created = yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + // The URI the user registered on the provider's own app. + callbackPort: 3118, + }); + expect(String(created)).toBe("acme-byo"); + + // The host reads the declaration BEFORE starting, to bind the URI. + expect( + yield* executor.oauth.loopbackCallback({ client: CLIENT, clientOwner: "org" }), + ).toEqual({ port: 3118, path: "/callback" }); + + // Listings carry it, so the connect UI can show the same URI. + const listed = yield* executor.oauth.listClients(); + const summary = listed.find((client) => client.slug === CLIENT); + expect(summary?.callbackPort).toBe(3118); + expect(summary?.callbackPath).toBe("/callback"); + + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("slack"), + integration: INTEG, + template: TEMPLATE, + redirectUri: "http://127.0.0.1:3118/callback", + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + + const authorize = new URL(started.authorizationUrl); + expect(authorize.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:3118/callback"); + + const callback = yield* server.completeAuthorizationCodeFlow({ + authorizationUrl: started.authorizationUrl, + }); + // The test server hands the code back to the URI the authorize request + // named, so the provider's redirect lands on the listener. + expect(callback.callbackUrl.startsWith("http://127.0.0.1:3118/callback?")).toBe(true); + + yield* server.clearRequests; + const connection = yield* executor.oauth.complete({ + state: started.state, + code: callback.code, + }); + expect(String(connection.address)).toBe("tools.acme.org.slack"); + + // The exchange must send the SAME redirect_uri as the authorize request: + // the test server rejects the code when they differ, and the token + // request is checked here so the guarantee is asserted rather than + // inferred from the exchange succeeding. + const requests = yield* server.requests; + const tokenRequest = requests.find((request) => request.path === "/token"); + expect(tokenRequest).toBeDefined(); + expect(new URLSearchParams(tokenRequest?.body ?? "").get("redirect_uri")).toBe( + "http://127.0.0.1:3118/callback", + ); + }), + ), + ); + + it.effect("sends the declared callback verbatim when the caller passes it", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + callbackPort: 3118, + callbackPath: "/oauth/cb", + }); + + // What a host does: read the URI it must serve, bind it, then start the + // flow with exactly that value. + const declared = yield* executor.oauth.loopbackCallback({ + client: CLIENT, + clientOwner: "org", + }); + if (declared === null) throw new Error("expected a declared loopback callback"); + + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("slack"), + integration: INTEG, + template: TEMPLATE, + redirectUri: oauthLoopbackCallbackUrl(declared), + }); + expect(started.status).toBe("redirect"); + if (started.status !== "redirect") return; + + expect(new URL(started.authorizationUrl).searchParams.get("redirect_uri")).toBe( + "http://127.0.0.1:3118/oauth/cb", + ); + }), + ), + ); + + it.effect("refuses a declared-callback flow the caller has not bound", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.acme.seed(); + + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + callbackPort: 3118, + }); + + const startInput = { + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("slack"), + integration: INTEG, + template: TEMPLATE, + } as const; + + // The connect UI always sends its own origin's callback; a caller that + // never asked for the declared one (an agent tool, a host that skips + // `loopbackCallback`) sends nothing. Both would send a redirect URI + // nothing is listening on — the provider would take the user all the way + // through consent and then refuse, so the flow is refused here instead. + for (const redirectUri of ["http://localhost:4788/api/oauth/callback", null]) { + const error = yield* Effect.flip(executor.oauth.start({ ...startInput, redirectUri })); + expect(Predicate.isTagged("OAuthStartError")(error)).toBe(true); + expect(error).toEqual( + expect.objectContaining({ + message: expect.stringContaining( + "requires the loopback callback http://127.0.0.1:3118/callback", + ), + }), + ); + } + }), + ), + ); + + it.effect("refuses a port the provider could never have registered", () => + Effect.scoped( + Effect.gen(function* () { + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const error = yield* Effect.flip( + executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: "https://acme.test/authorize", + tokenUrl: "https://acme.test/token", + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + callbackPort: 80, + }), + ); + expect(Predicate.isTagged("StorageError")(error)).toBe(true); + expect(error).toEqual( + expect.objectContaining({ message: expect.stringContaining("unprivileged port") }), + ); + expect(yield* executor.oauth.listClients()).toEqual([]); + }), + ), + ); + + it.effect("refuses a callback path that would not round-trip", () => + Effect.scoped( + Effect.gen(function* () { + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const error = yield* Effect.flip( + executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: "https://acme.test/authorize", + tokenUrl: "https://acme.test/token", + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + callbackPort: 3118, + callbackPath: "/callback?tenant=1", + }), + ); + expect(Predicate.isTagged("StorageError")(error)).toBe(true); + expect(error).toEqual( + expect.objectContaining({ message: expect.stringContaining("absolute path") }), + ); + }), + ), + ); + + it.effect("an app without a declaration reports none", () => + Effect.scoped( + Effect.gen(function* () { + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: "https://acme.test/authorize", + tokenUrl: "https://acme.test/token", + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + }); + + expect( + yield* executor.oauth.loopbackCallback({ client: CLIENT, clientOwner: "org" }), + ).toBeNull(); + expect( + yield* executor.oauth.loopbackCallback({ + client: OAuthClientSlug.make("never-registered"), + clientOwner: "org", + }), + ).toBeNull(); + }), + ), + ); +}); diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index 770437d908..eef32a87f7 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -52,6 +52,12 @@ import { firstPartyOAuthClientAllowsScopes, firstPartyOAuthClientSlug, isFirstPartyOAuthClientSlug, + MAX_OAUTH_LOOPBACK_CALLBACK_PORT, + MIN_OAUTH_LOOPBACK_CALLBACK_PORT, + normalizeOAuthLoopbackCallbackPath, + oauthClientLoopbackCallback, + oauthLoopbackCallbackUrl, + isOAuthLoopbackCallbackPort, parseStoredTokenEndpointAuthMethod, type ConnectResult, type CreateOAuthClientInput, @@ -62,6 +68,7 @@ import { type OAuthCompleteInput, type OAuthCompleteOptions, type OAuthGrant, + type OAuthLoopbackCallback, type OAuthProbeInput, type OAuthProbeResult, type OAuthService, @@ -557,6 +564,36 @@ const parseOAuthClientOrigin = (row: { }; }; +/** Parse the stored `callback_port` column into a port, or null when the app + * declares no loopback callback. The value is text like every other + * `oauth_client` column (it is metadata, not arithmetic), so it is converted + * here. A value that is not an unprivileged port reads as "no declared + * callback" instead of failing: that is the pre-existing behavior for every + * app, and a corrupt row must not make the app unusable. */ +const parseStoredCallbackPort = (value: unknown): number | null => { + if (value === null || value === undefined) return null; + const parsed = typeof value === "number" ? value : Number(String(value).trim()); + return isOAuthLoopbackCallbackPort(parsed) ? parsed : null; +}; + +/** The declared loopback callback of a stored row, as summary fields — or an + * empty spread when the row declares none (or its stored pair is unusable, so + * a corrupt row degrades to the pre-existing "no callback" behaviour). + * + * Absent rather than null, mirroring `tokenEndpointAuthMethod`: a summary + * reports what the app declares, and an app declaring no callback has nothing + * to report. */ +const storedLoopbackColumns = (row: { + readonly callback_port?: unknown; + readonly callback_path?: unknown; +}): { readonly callbackPort?: number; readonly callbackPath?: string } => { + const loopback = oauthClientLoopbackCallback({ + callbackPort: parseStoredCallbackPort(row.callback_port), + callbackPath: row.callback_path == null ? null : String(row.callback_path), + }); + return loopback === null ? {} : { callbackPort: loopback.port, callbackPath: loopback.path }; +}; + interface LoadedOAuthClient { readonly slug: string; readonly authorizationUrl: string; @@ -568,6 +605,10 @@ interface LoadedOAuthClient { readonly resource: string | null; readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod; readonly tokenRequestFormat?: "form" | "json"; + /** Declared loopback callback (see `oauthClientLoopbackCallback`). Null when + * the app uses the host's own callback. */ + readonly callbackPort?: number | null; + readonly callbackPath?: string | null; } /** Provider lifecycle scopes that are required to keep an authorization-code @@ -952,6 +993,27 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { cause: undefined, }); } + // A declared loopback callback is sent to the provider verbatim, so it has + // to be exactly the URI the user registered on their app. Reject a pair we + // would have to rewrite (privileged or out-of-range port, path with a + // query/fragment/whitespace) rather than storing it and sending something + // the provider will refuse mid-flow. + const callbackPort = input.callbackPort ?? null; + const callbackPath = normalizeOAuthLoopbackCallbackPath(input.callbackPath); + if (callbackPort !== null && !isOAuthLoopbackCallbackPort(callbackPort)) { + return yield* new StorageError({ + message: + `Loopback callback port must be an unprivileged port between ` + + `${MIN_OAUTH_LOOPBACK_CALLBACK_PORT} and ${MAX_OAUTH_LOOPBACK_CALLBACK_PORT}: ${callbackPort}`, + cause: undefined, + }); + } + if (callbackPath === null) { + return yield* new StorageError({ + message: `Loopback callback path must be an absolute path with no query or fragment: ${String(input.callbackPath)}`, + cause: undefined, + }); + } const keys = yield* Effect.try({ try: () => deps.ownedKeys(input.owner), catch: (cause) => @@ -1042,6 +1104,12 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { input.origin?.kind === "dynamic_client_registration" ? (input.originRedirectUri ?? null) : null, + // Declared loopback callback. The path is stored only alongside a + // port: a path on its own declares nothing, and storing the default + // would make "no callback" indistinguishable from "callback on the + // default path" for a later reader. + callback_port: callbackPort, + callback_path: callbackPort === null ? null : callbackPath, created_at: now, }), ); @@ -1599,6 +1667,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { clientId: String(row.client_id), ...(tokenEndpointAuthMethod === undefined ? {} : { tokenEndpointAuthMethod }), origin: parseOAuthClientOrigin(row), + ...storedLoopbackColumns(row), } satisfies OAuthClientSummary); }), ), @@ -1684,12 +1753,26 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { clientSecret, resource: row.resource == null ? null : String(row.resource), ...(tokenEndpointAuthMethod === undefined ? {} : { tokenEndpointAuthMethod }), + ...storedLoopbackColumns(row), } satisfies LoadedOAuthClient; }); }), ); }; + // ----------------------------------------------------------------------- + // loopbackCallback — the declared callback a host must bind before `start`. + // ----------------------------------------------------------------------- + const loopbackCallback = (input: { + readonly client: OAuthClientSlug; + readonly clientOwner: Owner; + }): Effect.Effect => + // Reads through the same (owner, slug) resolution `start` uses, so a caller + // cannot ask about an app the flow would refuse to run. + loadClient(input.clientOwner, input.client).pipe( + Effect.map((client) => (client === null ? null : oauthClientLoopbackCallback(client))), + ); + // ----------------------------------------------------------------------- // start — begin a flow through a client to mint a connection. // ----------------------------------------------------------------------- @@ -2014,7 +2097,27 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // authorization_code requires our callback to receive the code — fail // loudly if the executor was constructed without a redirectUri rather // than persisting a session pointed at a wrong localhost callback. - const flowRedirectUri = input.redirectUri ?? redirectUri; + // + // A declared loopback callback WINS over both the caller's redirect URI and + // the host default: the provider only accepts the URI registered on its own + // app, so anything else fails the authorize request outright. The host must + // have BOUND that URI first, and the only way to learn it is + // `loopbackCallback` — so a caller that did not pass it is a caller that + // never bound it, and its flow would strand the user at the provider with + // nothing listening. Refuse instead. + const declaredLoopback = oauthClientLoopbackCallback(client); + const flowRedirectUri = + declaredLoopback === null + ? (input.redirectUri ?? redirectUri) + : oauthLoopbackCallbackUrl(declaredLoopback); + if (declaredLoopback !== null && input.redirectUri !== flowRedirectUri) { + return yield* new OAuthStartError({ + message: + `The OAuth app ${String(input.client)} requires the loopback callback ${flowRedirectUri}, ` + + `which this flow was not started with. Read it from oauth.loopbackCallback, serve it, and ` + + `pass it as redirectUri; the local Executor app does that when you connect from its UI.`, + }); + } if (flowRedirectUri == null) { return yield* new OAuthStartError({ message: REDIRECT_URI_REQUIRED_MESSAGE, @@ -2580,6 +2683,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { removeClient, registerDynamicClient, listClients, + loopbackCallback, start, complete, cancel, diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index 391c12e3fa..e037587bd2 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -168,7 +168,16 @@ export { type OAuthClient, type OAuthClientOrigin, type OAuthClientSummary, + type OAuthLoopbackCallback, type CreateOAuthClientInput, + DEFAULT_OAUTH_LOOPBACK_CALLBACK_PATH, + MAX_OAUTH_LOOPBACK_CALLBACK_PORT, + MIN_OAUTH_LOOPBACK_CALLBACK_PORT, + OAUTH_LOOPBACK_CALLBACK_HOST, + isOAuthLoopbackCallbackPort, + normalizeOAuthLoopbackCallbackPath, + oauthClientLoopbackCallback, + oauthLoopbackCallbackUrl, type RegisterDynamicClientInput, type ConnectResult, type OAuthStartInput, diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx index 366cd52994..1f3e9a9e7c 100644 --- a/packages/react/src/api/atoms.tsx +++ b/packages/react/src/api/atoms.tsx @@ -2,6 +2,7 @@ import { ConnectionAddress, PolicyId, ProviderKey, + oauthClientLoopbackCallback, type ArtifactId, type AuthTemplateSlug, type Connection, @@ -607,6 +608,8 @@ export const createOAuthClientOptimistic = oauthClientsOptimisticAtom.pipe( readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod; readonly resource?: string | null; readonly originIntegration?: IntegrationSlug | null; + readonly callbackPort?: number | null; + readonly callbackPath?: string | null; }; }, ) => @@ -625,6 +628,18 @@ export const createOAuthClientOptimistic = oauthClientsOptimisticAtom.pipe( // Mirror the server's stamp so the just-registered app matches its // integration in the picker immediately (before the refetch lands). origin: { kind: "manual", integration: arg.payload.originIntegration ?? null }, + // Same for a declared loopback callback: a connect started before the + // refetch must already see the app's callback. Normalized through the + // same helper the server uses, and absent when the app declares none. + ...(() => { + const loopback = oauthClientLoopbackCallback({ + callbackPort: arg.payload.callbackPort ?? null, + callbackPath: arg.payload.callbackPath ?? null, + }); + return loopback === null + ? {} + : { callbackPort: loopback.port, callbackPath: loopback.path }; + })(), }; const index = rows.findIndex( (client) => client.owner === summary.owner && client.slug === summary.slug, diff --git a/packages/react/src/components/oauth-client-form.test.ts b/packages/react/src/components/oauth-client-form.test.ts index c0ea6f1ba0..d9fc40be67 100644 --- a/packages/react/src/components/oauth-client-form.test.ts +++ b/packages/react/src/components/oauth-client-form.test.ts @@ -4,7 +4,9 @@ import { Schema } from "effect"; import { canSubmitOAuthClientForm, + declaredLoopbackCallback, initialOAuthClientOwner, + loopbackCallbackError, preferredManualTokenEndpointAuthMethod, registrationScopes, resolveOriginIntegration, @@ -210,3 +212,39 @@ describe("oauthAppSetupFor", () => { expect(manifest.settings.agent_view).toBeUndefined(); }); }); + +describe("declaredLoopbackCallback", () => { + const base = { enabled: true, port: "3118", path: "/callback" } as const; + + it("declares nothing while the option is off, whatever the fields hold", () => { + expect(declaredLoopbackCallback({ ...base, enabled: false })).toBeNull(); + expect(loopbackCallbackError({ ...base, enabled: false })).toBeNull(); + }); + + it("declares the exact URI the user registered on the provider's app", () => { + expect(declaredLoopbackCallback(base)).toEqual({ port: 3118, path: "/callback" }); + expect(declaredLoopbackCallback({ ...base, path: "/oauth/cb" })).toEqual({ + port: 3118, + path: "/oauth/cb", + }); + // A blank path means the provider's default path, which is what gets sent. + expect(declaredLoopbackCallback({ ...base, path: " " })).toEqual({ + port: 3118, + path: "/callback", + }); + }); + + it("reports the port first, then the path, and declares nothing while either is bad", () => { + expect(loopbackCallbackError({ enabled: true, port: "", path: "/callback" })).toContain("port"); + expect(loopbackCallbackError({ enabled: true, port: "80", path: "/callback" })).toContain( + "1024 and 65535", + ); + expect(declaredLoopbackCallback({ enabled: true, port: "80", path: "/callback" })).toBeNull(); + + expect(loopbackCallbackError({ enabled: true, port: "3118", path: "/cb?x=1" })).toContain( + "absolute path", + ); + expect(declaredLoopbackCallback({ enabled: true, port: "3118", path: "/cb?x=1" })).toBeNull(); + expect(loopbackCallbackError(base)).toBeNull(); + }); +}); diff --git a/packages/react/src/components/oauth-client-form.tsx b/packages/react/src/components/oauth-client-form.tsx index d6e50b6157..494a25a284 100644 --- a/packages/react/src/components/oauth-client-form.tsx +++ b/packages/react/src/components/oauth-client-form.tsx @@ -3,10 +3,16 @@ import { useAtomSet } from "@effect/atom-react"; import * as Exit from "effect/Exit"; import { ExternalLink } from "lucide-react"; import { + DEFAULT_OAUTH_LOOPBACK_CALLBACK_PATH, + OAUTH_LOOPBACK_CALLBACK_HOST, + isOAuthLoopbackCallbackPort, isTokenEndpointAuthMethod, + normalizeOAuthLoopbackCallbackPath, + oauthLoopbackCallbackUrl, OAuthClientSlug, type IntegrationSlug, type OAuthGrant, + type OAuthLoopbackCallback, type Owner, type TokenEndpointAuthMethod, } from "@executor-js/sdk/shared"; @@ -72,6 +78,10 @@ export interface OAuthClientFormPrefill { readonly tokenEndpointAuthMethodsSupported?: readonly string[]; /** Saved manual-client transport. Omitted means client_secret_post. */ readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod; + /** Loopback callback the app's provider registration pins, when it is not + * this host's own callback. Seeded when editing an app that declares one. */ + readonly callbackPort?: number | null; + readonly callbackPath?: string | null; } export const preferredManualTokenEndpointAuthMethod = ( @@ -130,6 +140,40 @@ export const canSubmitOAuthClientForm = (input: { export const initialOAuthClientOwner = (fixedOwner: Owner | undefined): Owner => fixedOwner ?? "org"; +/** The loopback callback these form inputs declare, or null when they declare + * none (or name a pair we could not send to a provider verbatim). Pure, so the + * form's behaviour around a fixed callback is testable without a DOM. */ +export const declaredLoopbackCallback = (input: { + readonly enabled: boolean; + readonly port: string; + readonly path: string; +}): OAuthLoopbackCallback | null => { + if (!input.enabled) return null; + const port = Number(input.port.trim()); + if (input.port.trim().length === 0 || !isOAuthLoopbackCallbackPort(port)) return null; + const path = normalizeOAuthLoopbackCallbackPath(input.path); + return path === null ? null : { port, path }; +}; + +/** What to tell the user about the loopback fields, or null when they are fine. + * The port is the part providers pin, so a bad one is reported before a bad + * path — and the message names the range, since "3118" is not obviously a + * required shape. */ +export const loopbackCallbackError = (input: { + readonly enabled: boolean; + readonly port: string; + readonly path: string; +}): string | null => { + if (!input.enabled) return null; + const port = Number(input.port.trim()); + if (input.port.trim().length === 0 || !isOAuthLoopbackCallbackPort(port)) { + return "The callback port must be a free port between 1024 and 65535."; + } + return normalizeOAuthLoopbackCallbackPath(input.path) === null + ? "The callback path must be an absolute path, with no query or fragment." + : null; +}; + export function OAuthClientForm(props: { /** Human label for the integration this app backs (used in toasts + default name). */ readonly integrationName: string; @@ -194,7 +238,31 @@ export function OAuthClientForm(props: { // and to DCR registration below, so showing it here is exactly the redirect a // user must allow-list on their OAuth app. Resolved from `window.location` so // it is automatically correct per platform (cloud / self-host / local). - const callbackUrl = useMemo(() => oauthCallbackUrl(), []); + // + // A provider that does not support dynamic client registration only accepts a + // redirect URI already registered on ITS OAuth app, and such an app usually + // pins a loopback URI this host cannot otherwise offer (RFC 8252 §7.3). The + // user registers exactly one such URI on the provider's app and declares it + // here; the host serves it and sends it verbatim. + const [showLoopback, setShowLoopback] = useState(prefill?.callbackPort != null); + const [loopbackPort, setLoopbackPort] = useState( + prefill?.callbackPort == null ? "" : String(prefill.callbackPort), + ); + const [loopbackPath, setLoopbackPath] = useState( + prefill?.callbackPath ?? DEFAULT_OAUTH_LOOPBACK_CALLBACK_PATH, + ); + const declaredLoopback = declaredLoopbackCallback({ + enabled: showLoopback, + port: loopbackPort, + path: loopbackPath, + }); + const loopbackError = loopbackCallbackError({ + enabled: showLoopback, + port: loopbackPort, + path: loopbackPath, + }); + const callbackUrl = + declaredLoopback === null ? oauthCallbackUrl() : oauthLoopbackCallbackUrl(declaredLoopback); // Explicit create-time choice (no ambient owner). Admins default to Workspace // (`org`) on an org host; members are clamped to Personal (`user`); non-org @@ -262,16 +330,17 @@ export function OAuthClientForm(props: { const normalizedResource = resource == null || resource.trim().length === 0 ? null : resource.trim(); - const canSubmit = canSubmitOAuthClientForm({ - submitting, - name, - grant, - clientId, - clientSecret, - authorizationUrl, - tokenUrl, - tokenEndpointAuthMethod, - }); + const canSubmit = + canSubmitOAuthClientForm({ + submitting, + name, + grant, + clientId, + clientSecret, + authorizationUrl, + tokenUrl, + tokenEndpointAuthMethod, + }) && loopbackError === null; // DCR is offered when the server advertises a registration endpoint AND we // have the interactive-flow endpoints to persist alongside the minted client. @@ -284,7 +353,11 @@ export function OAuthClientForm(props: { // When the server already rejected automatic registration for this host (e.g. // it refused our non-loopback redirect URI), don't lead the user back into the // path that just failed: suppress the auto CTA and lead with manual entry. - const showAutoRegister = canRegisterDynamic && autoRegisterRejectedReason === null; + // A declared loopback callback also forces the manual path: it exists for apps + // whose provider registration already fixes the redirect URI, which is the + // opposite of letting the server register one. + const showAutoRegister = + canRegisterDynamic && autoRegisterRejectedReason === null && !showLoopback; const appSetup = oauthAppSetupFor({ authorizationUrl, tokenUrl, @@ -382,6 +455,11 @@ export function OAuthClientForm(props: { // `intentIntegration`, passed verbatim by the caller); a fresh // registration from an integration's dialog stamps recorded intent. originIntegration: resolveOriginIntegration(intentIntegration, integrationSlug), + // The declared loopback callback, or null to clear one the app used to + // have — `createClient` upserts, so an edit that turns it off must send + // the absence explicitly. + callbackPort: declaredLoopback?.port ?? null, + callbackPath: declaredLoopback?.path ?? null, }, reactivityKeys: oauthClientWriteKeys, }); @@ -528,23 +606,90 @@ export function OAuthClientForm(props: { so the user can allow-list it on their OAuth app. Client-credentials has no browser redirect, so it is hidden for that grant. */} {grant === "authorization_code" ? ( -
- -
- +
+ +
+ + {callbackUrl} + + +
+
+ + {/* Some providers have no registration endpoint and only allow a + redirect URI registered on their own app, which usually pins a + loopback port (RFC 8252 §7.3). Declaring it here makes the host + serve that exact URI instead of this host's own callback. */} +
+ + {showLoopback ? ( +
+
+ + http://{OAUTH_LOOPBACK_CALLBACK_HOST}: + + ) => + setLoopbackPort(e.target.value) + } + className="w-24 font-mono" + aria-label="Loopback callback port" + /> + ) => + setLoopbackPath(e.target.value) + } + className="min-w-0 flex-1 font-mono" + aria-label="Loopback callback path" + /> +
+

+ Only works when the browser and Executor are on the same machine. Nothing else may + be listening on that port while you sign in. +

+ {loopbackError ?

{loopbackError}

: null} +
+ ) : null}
-
+ ) : null} {/* client id / secret */} From f2044b3bae5a1ba4539681ade844925d0839888b Mon Sep 17 00:00:00 2001 From: Sahil Shaikh Date: Tue, 29 Sep 2026 20:53:38 +0000 Subject: [PATCH 2/2] Fix lint violations in the loopback callback change CI's oxlint runs the repo's own Effect-hygiene rules, which this change tripped in six ways: inspecting Bun's foreign bind failure with `instanceof Error` and reading its `message`, a try/catch in the callback-path normalizer, `throw` and `new Error` in tests, a promise executor `reject`, manual `_tag` checks, and a raw `` in the connect form. Each is now modelled rather than probed: the path normalizer uses `URL.canParse`, the bind failure is read through a named adapter-boundary helper that only looks at its `code`, the test helpers bind a real socket to find a free port, tagged failures are asserted through their fields, and the form uses the design system's ``. --- apps/local/src/oauth-loopback-api.test.ts | 104 +++++++++--------- apps/local/src/oauth-loopback.test.ts | 57 +++++----- apps/local/src/oauth-loopback.ts | 41 ++++--- packages/core/sdk/src/oauth-client.ts | 14 +-- .../sdk/src/oauth-loopback-callback.test.ts | 7 +- .../src/components/oauth-client-form.tsx | 8 +- 6 files changed, 114 insertions(+), 117 deletions(-) diff --git a/apps/local/src/oauth-loopback-api.test.ts b/apps/local/src/oauth-loopback-api.test.ts index f4f8b12c0c..cb02e1c9b0 100644 --- a/apps/local/src/oauth-loopback-api.test.ts +++ b/apps/local/src/oauth-loopback-api.test.ts @@ -21,7 +21,6 @@ import { randomBytes } from "node:crypto"; import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createServer } from "node:net"; import { HttpApi, HttpApiBuilder, HttpApiClient } from "effect/unstable/httpapi"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; @@ -66,20 +65,19 @@ const TEST_BASE_URL = "http://local.test"; * daemon's default. The loopback listener forwards here. */ const DAEMON_ORIGIN = "http://localhost:4788"; -const freePort = async (): Promise => - new Promise((resolve, reject) => { - const probe = createServer(); - probe.once("error", reject); - probe.listen(0, "127.0.0.1", () => { - const address = probe.address(); - if (address === null || typeof address === "string") { - reject(new Error("probe socket has no port")); - return; - } - const port = address.port; - probe.close(() => resolve(port)); - }); +/** A genuinely free port: bind one, read it, release it. */ +const freePort = (): number => { + const probe = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: () => new Response(""), }); + // A successful bind always has a port; the assertion only satisfies Bun's + // optional-typed property (same shape as `serve.ts` reporting a live port). + const port = probe.port!; + probe.stop(true); + return port; +}; interface Harness { /** The in-process web handler, reached through this origin. */ @@ -173,6 +171,8 @@ const startHarness = async (tmpDir: string): Promise => { const tmpDirs: string[] = []; const harnesses: Harness[] = []; +/** Sockets a test stands up itself; stopped after the test. */ +const openServers: { stop: (closeActiveConnections?: boolean) => void }[] = []; const openHarness = async (): Promise => { const tmpDir = mkdtempSync(join(tmpdir(), "executor-local-loopback-")); @@ -183,6 +183,7 @@ const openHarness = async (): Promise => { }; afterEach(async () => { + for (const server of openServers.splice(0)) server.stop(true); while (harnesses.length > 0) await harnesses.pop()?.dispose(); while (tmpDirs.length > 0) { const dir = tmpDirs.pop(); @@ -198,7 +199,7 @@ describe("local oauth loopback callback (real API, real sockets)", () => { Effect.gen(function* () { const harness = yield* Effect.promise(() => openHarness()); const oauth = yield* serveOAuthTestServer({ scopes: ["read"] }); - const callbackPort = yield* Effect.promise(() => freePort()); + const callbackPort = yield* Effect.sync(freePort); const clientLayer = FetchHttpClient.layer.pipe( Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(harness.fetch)), ); @@ -298,12 +299,13 @@ describe("local oauth loopback callback (real API, real sockets)", () => { Effect.gen(function* () { const harness = yield* Effect.promise(() => openHarness()); const oauth = yield* serveOAuthTestServer({ scopes: ["read"] }); - const callbackPort = yield* Effect.promise(() => freePort()); + const callbackPort = yield* Effect.sync(freePort); const holder = Bun.serve({ hostname: "127.0.0.1", port: callbackPort, fetch: () => new Response("held"), }); + openServers.push(holder); const clientLayer = FetchHttpClient.layer.pipe( Layer.provide(Layer.succeed(FetchHttpClient.Fetch)(harness.fetch)), ); @@ -314,48 +316,44 @@ describe("local oauth loopback callback (real API, real sockets)", () => { return yield* body(client); }).pipe(Effect.provide(clientLayer)) as Effect.Effect; - try { - yield* harness.registerRemoteServer({ - slug: "mcp_remote", - endpoint: oauth.mcpResourceUrl, - }); + yield* harness.registerRemoteServer({ + slug: "mcp_remote", + endpoint: oauth.mcpResourceUrl, + }); - const slug = `slack-${randomBytes(4).toString("hex")}`; - yield* run((client) => - client.oauth.createClient({ + const slug = `slack-${randomBytes(4).toString("hex")}`; + yield* run((client) => + client.oauth.createClient({ + payload: { + owner: "org", + slug: OAuthClientSlug.make(slug), + authorizationUrl: oauth.authorizationEndpoint, + tokenUrl: oauth.tokenEndpoint, + grant: "authorization_code", + clientId: "test-client", + clientSecret: "test-secret", + callbackPort, + }, + }), + ); + + const failure = yield* Effect.flip( + run((client) => + client.oauth.start({ payload: { + client: OAuthClientSlug.make(slug), + clientOwner: "org", owner: "org", - slug: OAuthClientSlug.make(slug), - authorizationUrl: oauth.authorizationEndpoint, - tokenUrl: oauth.tokenEndpoint, - grant: "authorization_code", - clientId: "test-client", - clientSecret: "test-secret", - callbackPort, + name: ConnectionName.make("slack"), + integration: IntegrationSlug.make("mcp_remote"), + template: AuthTemplateSlug.make("oauth"), }, }), - ); - - const failure = yield* Effect.flip( - run((client) => - client.oauth.start({ - payload: { - client: OAuthClientSlug.make(slug), - clientOwner: "org", - owner: "org", - name: ConnectionName.make("slack"), - integration: IntegrationSlug.make("mcp_remote"), - template: AuthTemplateSlug.make("oauth"), - }, - }), - ), - ); - expect(String((failure as { readonly message?: string }).message)).toContain( - `Port ${callbackPort} is already in use`, - ); - } finally { - holder.stop(true); - } + ), + ); + expect(String((failure as { readonly message?: string }).message)).toContain( + `Port ${callbackPort} is already in use`, + ); }), ), 30_000, diff --git a/apps/local/src/oauth-loopback.test.ts b/apps/local/src/oauth-loopback.test.ts index 1bbdab4863..20eca5d53f 100644 --- a/apps/local/src/oauth-loopback.test.ts +++ b/apps/local/src/oauth-loopback.test.ts @@ -1,6 +1,5 @@ import { afterEach, describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; -import { createServer } from "node:net"; import { makeOAuthLoopbackListener, @@ -26,20 +25,18 @@ afterEach(() => { }); /** A genuinely free port: bind one, read it, release it. */ -const freePort = async (): Promise => - new Promise((resolve, reject) => { - const probe = createServer(); - probe.once("error", reject); - probe.listen(0, "127.0.0.1", () => { - const address = probe.address(); - if (address === null || typeof address === "string") { - reject(new Error("probe socket has no port")); - return; - } - const port = address.port; - probe.close(() => resolve(port)); - }); +const freePort = (): number => { + const probe = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch: () => new Response(""), }); + // A successful bind always has a port; the assertion only satisfies Bun's + // optional-typed property (same shape as `serve.ts` reporting a live port). + const port = probe.port!; + probe.stop(true); + return port; +}; const listening = (webBaseUrl: string): LocalOAuthLoopbackListener => { const listener = makeOAuthLoopbackListener(webBaseUrl); @@ -49,8 +46,8 @@ const listening = (webBaseUrl: string): LocalOAuthLoopbackListener => { describe("local loopback OAuth callback listener", () => { it("forwards the provider's callback to the daemon's completion route", async () => { - const daemonPort = await freePort(); - const callbackPort = await freePort(); + const daemonPort = freePort(); + const callbackPort = freePort(); const seen: string[] = []; openServers.push( Bun.serve({ @@ -89,8 +86,8 @@ describe("local loopback OAuth callback listener", () => { }); it("serves only the declared path", async () => { - const callbackPort = await freePort(); - const listener = listening(`http://127.0.0.1:${await freePort()}`); + const callbackPort = freePort(); + const listener = listening(`http://127.0.0.1:${freePort()}`); await Effect.runPromise(listener.listen(`http://127.0.0.1:${callbackPort}/oauth/cb`)); const wrongPath = await fetch(`http://127.0.0.1:${callbackPort}/callback?code=a&state=b`, { @@ -106,23 +103,22 @@ describe("local loopback OAuth callback listener", () => { }); it("reports a port another process already holds, naming the likely culprit", async () => { - const callbackPort = await freePort(); + const callbackPort = freePort(); openServers.push( Bun.serve({ hostname: "127.0.0.1", port: callbackPort, fetch: () => new Response("taken") }), ); - const listener = listening(`http://127.0.0.1:${await freePort()}`); + const listener = listening(`http://127.0.0.1:${freePort()}`); const failure = await Effect.runPromise( Effect.flip(listener.listen(`http://127.0.0.1:${callbackPort}/callback`)), ); - expect(failure._tag).toBe("OAuthLoopbackListenError"); expect(failure.message).toContain(`Port ${callbackPort} is already in use`); expect(failure.message).toContain("Claude Code"); }); it("reuses a listener already bound instead of contending with itself", async () => { - const callbackPort = await freePort(); - const listener = listening(`http://127.0.0.1:${await freePort()}`); + const callbackPort = freePort(); + const listener = listening(`http://127.0.0.1:${freePort()}`); const url = `http://127.0.0.1:${callbackPort}/callback`; await Effect.runPromise(listener.listen(url)); @@ -134,8 +130,8 @@ describe("local loopback OAuth callback listener", () => { }); it("releases the port when the flow is over", async () => { - const callbackPort = await freePort(); - const listener = listening(`http://127.0.0.1:${await freePort()}`); + const callbackPort = freePort(); + const listener = listening(`http://127.0.0.1:${freePort()}`); await Effect.runPromise(listener.listen(`http://127.0.0.1:${callbackPort}/callback`)); expect( ( @@ -157,8 +153,8 @@ describe("local loopback OAuth callback listener", () => { }); it("keeps serving a URI two flows share after the first one completes", async () => { - const daemonPort = await freePort(); - const callbackPort = await freePort(); + const daemonPort = freePort(); + const callbackPort = freePort(); openServers.push( Bun.serve({ hostname: "127.0.0.1", @@ -187,8 +183,8 @@ describe("local loopback OAuth callback listener", () => { }); it("closes behind a callback it served for a single flow", async () => { - const callbackPort = await freePort(); - const listener = listening(`http://127.0.0.1:${await freePort()}`); + const callbackPort = freePort(); + const listener = listening(`http://127.0.0.1:${freePort()}`); const url = `http://127.0.0.1:${callbackPort}/callback`; await Effect.runPromise(listener.listen(url)); expect((await fetch(`${url}?code=a&state=b`, { redirect: "manual" })).status).toBe(302); @@ -208,9 +204,8 @@ describe("local loopback OAuth callback listener", () => { it("refuses a callback when the browser is not on this machine", async () => { const listener = listening("https://executor.example.com"); const failure = await Effect.runPromise( - Effect.flip(listener.listen(`http://127.0.0.1:${await freePort()}/callback`)), + Effect.flip(listener.listen(`http://127.0.0.1:${freePort()}/callback`)), ); - expect(failure._tag).toBe("OAuthLoopbackListenError"); expect(failure.message).toContain("same machine as the browser"); }); }); diff --git a/apps/local/src/oauth-loopback.ts b/apps/local/src/oauth-loopback.ts index d2779d8eb5..05daf0cee1 100644 --- a/apps/local/src/oauth-loopback.ts +++ b/apps/local/src/oauth-loopback.ts @@ -54,16 +54,21 @@ interface BoundCallback { readonly server: { stop: (closeActiveConnections?: boolean) => void }; } -const addressInUse = (cause: unknown): boolean => { - if (cause instanceof Error) { - const code = (cause as { readonly code?: unknown }).code; - return ( - code === "EADDRINUSE" || - /EADDRINUSE|address already in use/i.test(cause.message) || - /address already in use/i.test(cause.name) - ); - } - return /EADDRINUSE|address already in use/i.test(String(cause)); +/** The `code` a foreign bind failure carries, when it carries one. + * + * `Bun.serve` throws a plain Error, so "the port is taken" can only be read out + * of its `code` — a true adapter boundary, and the one place here that looks at + * a foreign failure's shape at all. */ +interface ForeignBindFailure { + readonly code?: unknown; +} + +const PORT_IN_USE = "EADDRINUSE"; + +const foreignBindCode = (thrown: unknown): string | null => { + if (typeof thrown !== "object" || thrown === null) return null; + const code = (thrown as ForeignBindFailure).code; + return typeof code === "string" ? code : null; }; /** The page a browser sees when it reaches a loopback callback URL of a path we @@ -188,16 +193,16 @@ export const makeOAuthLoopbackListener = (webBaseUrl: string): LocalOAuthLoopbac armTtl(url, entry); bound.set(url, entry); }, - catch: (cause) => + catch: (thrown) => new OAuthLoopbackListenError({ url, - message: addressInUse(cause) - ? `Port ${port} is already in use, so Executor cannot serve ${url}. ` + - `Another client (Claude Code, Cursor, …) may be mid-sign-in on that port. ` + - `Finish or close that flow, or register a different callback port for this app.` - : `Could not serve the loopback callback ${url}: ${ - cause instanceof Error ? cause.message : String(cause) - }`, + message: + foreignBindCode(thrown) === PORT_IN_USE + ? `Port ${port} is already in use, so Executor cannot serve ${url}. ` + + `Another client (Claude Code, Cursor, …) may be mid-sign-in on that port. ` + + `Finish or close that flow, or register a different callback port for this app.` + : `Could not serve the loopback callback ${url}: this machine refused a ` + + `listener on port ${port}. Something else may be holding it.`, }), }); }); diff --git a/packages/core/sdk/src/oauth-client.ts b/packages/core/sdk/src/oauth-client.ts index 83a5c1095f..78dc681c53 100644 --- a/packages/core/sdk/src/oauth-client.ts +++ b/packages/core/sdk/src/oauth-client.ts @@ -198,14 +198,12 @@ export const normalizeOAuthLoopbackCallbackPath = ( const candidate = path == null ? "" : path.trim(); if (candidate.length === 0) return DEFAULT_OAUTH_LOOPBACK_CALLBACK_PATH; if (!candidate.startsWith("/")) return null; - try { - const parsed = new URL(`http://${OAUTH_LOOPBACK_CALLBACK_HOST}${candidate}`); - return parsed.pathname === candidate && parsed.search === "" && parsed.hash === "" - ? candidate - : null; - } catch { - return null; - } + const absolute = `http://${OAUTH_LOOPBACK_CALLBACK_HOST}${candidate}`; + if (!URL.canParse(absolute)) return null; + const parsed = new URL(absolute); + return parsed.pathname === candidate && parsed.search === "" && parsed.hash === "" + ? candidate + : null; }; /** The loopback callback an app declares, or null when it declares none. A path diff --git a/packages/core/sdk/src/oauth-loopback-callback.test.ts b/packages/core/sdk/src/oauth-loopback-callback.test.ts index 7bedcecd44..8c35ecdcf0 100644 --- a/packages/core/sdk/src/oauth-loopback-callback.test.ts +++ b/packages/core/sdk/src/oauth-loopback-callback.test.ts @@ -210,13 +210,14 @@ describe("oauth loopback callback", () => { callbackPath: "/oauth/cb", }); - // What a host does: read the URI it must serve, bind it, then start the - // flow with exactly that value. + // What a host does: read the URI it must serve (asserted above), bind it, + // then start the flow with exactly that value. const declared = yield* executor.oauth.loopbackCallback({ client: CLIENT, clientOwner: "org", }); - if (declared === null) throw new Error("expected a declared loopback callback"); + expect(declared).toEqual({ port: 3118, path: "/oauth/cb" }); + if (declared === null) return; const started = yield* executor.oauth.start({ owner: "org", diff --git a/packages/react/src/components/oauth-client-form.tsx b/packages/react/src/components/oauth-client-form.tsx index 494a25a284..c478205838 100644 --- a/packages/react/src/components/oauth-client-form.tsx +++ b/packages/react/src/components/oauth-client-form.tsx @@ -32,6 +32,7 @@ import { normalizeConnectionOwner, } from "../plugins/connection-owner"; import { Button } from "./button"; +import { Checkbox } from "./checkbox"; import { CopyButton } from "./copy-button"; import { Input } from "./input"; import { Label } from "./label"; @@ -634,13 +635,12 @@ export function OAuthClientForm(props: { htmlFor="oauth-loopback-callback" className="flex cursor-pointer items-start gap-3 font-normal" > - ) => - setShowLoopback(e.target.checked) + onCheckedChange={(checked: boolean | "indeterminate") => + setShowLoopback(checked === true) } />