From f0f920fe4f2ccd274017e24b0ef6bc36037d429c Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:09:12 -0700 Subject: [PATCH 1/2] Keep DCR OAuth requests within the client's registered scopes --- .changeset/dcr-registered-scopes.md | 5 + .../cloud/drizzle/0023_blushing_thor_girl.sql | 1 + apps/cloud/drizzle/meta/0023_snapshot.json | 1825 +++++++++++++++++ apps/cloud/drizzle/meta/_journal.json | 7 + apps/cloud/src/db/executor-schema.ts | 1 + apps/local/drizzle/0007_lethal_nebula.sql | 1 + apps/local/drizzle/meta/0007_snapshot.json | 955 +++++++++ apps/local/drizzle/meta/_journal.json | 7 + apps/local/src/db/executor-schema.ts | 1 + apps/local/src/db/sqlite-fumadb.ts | 6 + packages/core/sdk/src/core-schema.ts | 9 + packages/core/sdk/src/oauth-client.ts | 4 + packages/core/sdk/src/oauth-service.ts | 86 +- 13 files changed, 2896 insertions(+), 12 deletions(-) create mode 100644 .changeset/dcr-registered-scopes.md create mode 100644 apps/cloud/drizzle/0023_blushing_thor_girl.sql create mode 100644 apps/cloud/drizzle/meta/0023_snapshot.json create mode 100644 apps/local/drizzle/0007_lethal_nebula.sql create mode 100644 apps/local/drizzle/meta/0007_snapshot.json diff --git a/.changeset/dcr-registered-scopes.md b/.changeset/dcr-registered-scopes.md new file mode 100644 index 0000000000..17944f30c9 --- /dev/null +++ b/.changeset/dcr-registered-scopes.md @@ -0,0 +1,5 @@ +--- +"@executor-js/sdk": patch +--- + +Keep OAuth requests within the scopes a dynamically registered client registered for. DCR clients now persist their registered scopes (`oauth_client.origin_scopes`). Reuse picks a client only when its registration covers the requested scopes, so reconnecting after a server adds scopes registers a new client instead of pairing the old one with scopes the server refuses with `invalid_scope`. `oauth.start` narrows discovered scopes to the client's registration and fails with a clear error when declared scopes fall outside it. Clients registered before this release have no recorded scopes and are registered again on their next connect; existing connections keep refreshing through them. diff --git a/apps/cloud/drizzle/0023_blushing_thor_girl.sql b/apps/cloud/drizzle/0023_blushing_thor_girl.sql new file mode 100644 index 0000000000..50ccd393e5 --- /dev/null +++ b/apps/cloud/drizzle/0023_blushing_thor_girl.sql @@ -0,0 +1 @@ +ALTER TABLE "oauth_client" ADD COLUMN "origin_scopes" text; \ No newline at end of file diff --git a/apps/cloud/drizzle/meta/0023_snapshot.json b/apps/cloud/drizzle/meta/0023_snapshot.json new file mode 100644 index 0000000000..13d0374170 --- /dev/null +++ b/apps/cloud/drizzle/meta/0023_snapshot.json @@ -0,0 +1,1825 @@ +{ + "id": "f8434522-4dec-4ec3-a98d-7835dd9ed956", + "prevId": "9d3bc667-63af-4a1b-99d9-b490ffcd80ef", + "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.org_moved_to_v2": { + "name": "org_moved_to_v2", + "schema": "", + "columns": { + "org_id": { + "name": "org_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "v2_org_id": { + "name": "v2_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "v2_org_slug": { + "name": "v2_org_slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "org_moved_to_v2_org_id_organizations_id_fk": { + "name": "org_moved_to_v2_org_id_organizations_id_fk", + "tableFrom": "org_moved_to_v2", + "tableTo": "organizations", + "columnsFrom": ["org_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "org_moved_to_v2_state_check": { + "name": "org_moved_to_v2_state_check", + "value": "\"org_moved_to_v2\".\"state\" in ('moving', 'moved', 'rolled_back')" + } + }, + "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 + }, + "origin_scopes": { + "name": "origin_scopes", + "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 f751865445..4422556d24 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1792714563896, "tag": "0022_org_moved_to_v2_slug", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1793066713689, + "tag": "0023_blushing_thor_girl", + "breakpoints": true } ] } diff --git a/apps/cloud/src/db/executor-schema.ts b/apps/cloud/src/db/executor-schema.ts index 0db709b884..c8a19ba62d 100644 --- a/apps/cloud/src/db/executor-schema.ts +++ b/apps/cloud/src/db/executor-schema.ts @@ -106,6 +106,7 @@ export const oauth_client = pgTable( origin_integration: text("origin_integration"), origin_issuer: text("origin_issuer"), origin_redirect_uri: text("origin_redirect_uri"), + origin_scopes: text("origin_scopes"), created_at: timestamp("created_at").notNull(), row_id: varchar("row_id", { length: 255 }) .primaryKey() diff --git a/apps/local/drizzle/0007_lethal_nebula.sql b/apps/local/drizzle/0007_lethal_nebula.sql new file mode 100644 index 0000000000..52cbbd2750 --- /dev/null +++ b/apps/local/drizzle/0007_lethal_nebula.sql @@ -0,0 +1 @@ +ALTER TABLE `oauth_client` ADD `origin_scopes` 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..8151cdefff --- /dev/null +++ b/apps/local/drizzle/meta/0007_snapshot.json @@ -0,0 +1,955 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "ccdfb606-c5fd-4f3c-8296-2667eb31cd1a", + "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 + }, + "origin_scopes": { + "name": "origin_scopes", + "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..2933003906 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": 1793066713918, + "tag": "0007_lethal_nebula", + "breakpoints": true } ] } diff --git a/apps/local/src/db/executor-schema.ts b/apps/local/src/db/executor-schema.ts index a7f6ceb48e..0b087b714e 100644 --- a/apps/local/src/db/executor-schema.ts +++ b/apps/local/src/db/executor-schema.ts @@ -80,6 +80,7 @@ export const oauth_client = sqliteTable( origin_integration: text("origin_integration"), origin_issuer: text("origin_issuer"), origin_redirect_uri: text("origin_redirect_uri"), + origin_scopes: text("origin_scopes"), created_at: integer("created_at").notNull(), row_id: text("row_id").primaryKey().notNull(), tenant: text("tenant").notNull(), diff --git a/apps/local/src/db/sqlite-fumadb.ts b/apps/local/src/db/sqlite-fumadb.ts index 7ffb8128a1..1b019ce476 100644 --- a/apps/local/src/db/sqlite-fumadb.ts +++ b/apps/local/src/db/sqlite-fumadb.ts @@ -105,6 +105,12 @@ export const createSqliteFumaDb = async ( ) { await client.execute("ALTER TABLE oauth_client ADD COLUMN origin_redirect_uri TEXT"); } + if ( + oauthClientColumns.rows.length > 0 && + !oauthClientColumns.rows.some((column) => column["name"] === "origin_scopes") + ) { + await client.execute("ALTER TABLE oauth_client ADD COLUMN origin_scopes TEXT"); + } const { db, fuma } = createExecutorFumaDb(drizzleDb, { tables: options.tables, diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index 8014584695..5c232fffa9 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 space-delimited scopes a DCR client registered for (RFC 7591 + // `scope`); empty when the registration sent none. A server may refuse + // an authorize request for a scope the client was not registered for, so + // reuse only picks a client whose registration covers the request, and + // start never asks for more. Null for manual clients and for DCR rows + // written before this column existed: unlike the redirect URI, a missing + // record is not treated as covering, because a server that has since + // added scopes rejects every reconnect through the old client. + origin_scopes: nullableTextColumn("origin_scopes"), created_at: dateColumn("created_at"), }, ["tenant", "owner", "subject", "slug"], diff --git a/packages/core/sdk/src/oauth-client.ts b/packages/core/sdk/src/oauth-client.ts index 8778205014..6b322aac7c 100644 --- a/packages/core/sdk/src/oauth-client.ts +++ b/packages/core/sdk/src/oauth-client.ts @@ -282,6 +282,10 @@ export type CreateOAuthClientInput = OAuthClient & { * reject an authorize request whose redirect_uri differs from the * registration). Ignored for manual clients. */ readonly originRedirectUri?: string | null; + /** The scopes a DCR registration sent as the client's `scope`. Persisted so + * reuse and start never ask the server for scopes the client was not + * registered for. Ignored for manual clients. */ + readonly originScopes?: readonly string[] | null; }; /** Metadata-only projection of a registered client for listing in the UI. diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index f056211040..cdb62e23ce 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -571,8 +571,16 @@ interface LoadedOAuthClient { readonly resource: string | null; readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod; readonly tokenRequestFormat?: "form" | "json"; + /** The scopes a DCR client registered for, when its registration recorded + * any. A server may refuse an authorize request for a scope outside them. */ + readonly registeredScopes?: readonly string[]; } +/** Parse a stored `origin_scopes` value: null when none was recorded, an empty + * list when the registration sent no `scope`. */ +const parseStoredScopes = (value: unknown): readonly string[] | null => + value == null ? null : String(value).split(/\s+/).filter(Boolean); + /** Provider lifecycle scopes that are required to keep an authorization-code * connection renewable but are omitted from the protected resource's API * scope list. Vercel's MCP resource advertises only `openid`, while its @@ -1045,6 +1053,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { input.origin?.kind === "dynamic_client_registration" ? (input.originRedirectUri ?? null) : null, + origin_scopes: + input.origin?.kind === "dynamic_client_registration" && input.originScopes != null + ? input.originScopes.join(" ") + : null, created_at: now, }), ); @@ -1288,8 +1300,14 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { /** Redirect URI the candidate registered with the AS; null for rows * predating the column. */ readonly redirectUri: string | null; + /** Scopes the candidate registered for; null for rows predating the column. */ + readonly scopes: readonly string[] | null; }; + /** The scopes a DCR registration for `input` asks for. */ + const dcrRegistrationScopes = (input: RegisterDynamicClientInput): readonly string[] => + dedupeScopes([...input.scopes, ...additionalAuthorizationLifecycleScopes(input)]); + // `oauth_client.created_at` is a date column that surfaces as a Date, an ISO // string, or an epoch number depending on the storage backend. Normalize to // epoch ms for deterministic oldest-first ordering; an unparseable/missing @@ -1338,6 +1356,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { resource: row.resource == null ? null : String(row.resource), redirectUri: row.origin_redirect_uri == null ? null : String(row.origin_redirect_uri), + scopes: parseStoredScopes(row.origin_scopes), createdAt: candidateCreatedAt(row.created_at), }, ]; @@ -1354,10 +1373,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { a.createdAt - b.createdAt || (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0), ) .map( - ({ slug, resource, redirectUri }): DcrReuseCandidate => ({ + ({ slug, resource, redirectUri, scopes }): DcrReuseCandidate => ({ slug, resource, redirectUri, + scopes, }), ); }), @@ -1388,6 +1408,21 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { if (hasExplicitRedirectUri) return false; return candidate.redirectUri === null || flowRedirectUri === null; }; + // A client is reusable only when its registration covers every scope this + // one asks for: a server may refuse an authorize request for a scope the + // client was not registered for, and a server that has since added scopes + // would otherwise reject every reconnect through the old client. A row + // with no recorded scopes is not proof of coverage. + const registrationScopes = dcrRegistrationScopes(input); + const scopesCovered = (candidate: DcrReuseCandidate): boolean => { + const registered = candidate.scopes; + if (registrationScopes.length === 0) return true; + return ( + registered !== null && registrationScopes.every((scope) => registered.includes(scope)) + ); + }; + const reusableFor = (candidate: DcrReuseCandidate): boolean => + redirectMatches(candidate) && scopesCovered(candidate); // A fresh registration must never take a slug an existing candidate // holds: `createClient` deletes any colliding (owner, slug) row first, // which would clobber a client that live connections still refresh @@ -1403,7 +1438,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // reconnects must reuse that instead of registering another duplicate // each time. const reusable = candidates.find( - (client) => client.resource === resource && redirectMatches(client), + (client) => client.resource === resource && reusableFor(client), ); if (reusable) { return { existingSlug: reusable.slug, registrationSlug: reusable.slug }; @@ -1423,9 +1458,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // resource-less flow (its tokens are bound to that resource), so when only // resource-scoped candidates exist we register a fresh resource-less client // rather than silently borrowing one (the old `?? candidates[0]` bug). - const reusable = candidates.find( - (client) => client.resource === null && redirectMatches(client), - ); + const reusable = candidates.find((client) => client.resource === null && reusableFor(client)); if (reusable) return { existingSlug: reusable.slug, registrationSlug: reusable.slug }; // Fresh resource-less client. Its slug is the bare `dcr-` base, but // the FIRST resource-scoped registration for an issuer also takes that base @@ -1460,10 +1493,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }); } const authMethod = pickDcrAuthMethod(input.tokenEndpointAuthMethodsSupported); - const registrationScopes = dedupeScopes([ - ...input.scopes, - ...additionalAuthorizationLifecycleScopes(input), - ]); + const registrationScopes = dcrRegistrationScopes(input); const information = yield* registerDynamicClientDcr( { registrationEndpoint: input.registrationEndpoint, @@ -1503,8 +1533,8 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { // store "" so the PKCE-only token exchange omits `client_secret`. // Confidential DCR clients keep the returned secret in the credential // provider. The persisted grant is interactive authorization_code. - // `input.scopes` was already sent to the AS at registration above; the - // stored client carries no scope set (the integration drives requests). + // The integration drives what a flow requests; the registered scopes + // are recorded only as the bound of what the server will accept. yield* createClient({ owner: input.owner, slug, @@ -1520,6 +1550,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }, originIssuer: issuer, originRedirectUri: flowRedirectUri, + originScopes: registrationScopes, }); return slug; }); @@ -1647,6 +1678,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { const tokenEndpointAuthMethod = parseStoredTokenEndpointAuthMethod( row.token_endpoint_auth_method, ); + const registeredScopes = + parseOAuthClientOrigin(row).kind === "dynamic_client_registration" + ? parseStoredScopes(row.origin_scopes) + : null; if (tokenEndpointAuthMethod === null) { return Effect.fail( new StorageError({ @@ -1687,6 +1722,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { clientSecret, resource: row.resource == null ? null : String(row.resource), ...(tokenEndpointAuthMethod === undefined ? {} : { tokenEndpointAuthMethod }), + ...(registeredScopes === null || registeredScopes.length === 0 + ? {} + : { registeredScopes }), } satisfies LoadedOAuthClient; }); }), @@ -1790,7 +1828,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { ), ); const firstParty = firstPartyFlow ? firstPartyBySlug.get(String(input.client)) : undefined; - const requestedScopes = + const unboundedScopes = scopePolicy.kind === "discover" ? yield* (() => { // Scope discovery reads protected-resource metadata. The client's @@ -1817,6 +1855,30 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { })() : dedupeScopes(scopePolicy.scopes); + // A DCR client's registration bounds what its server will authorize, so + // never ask for a scope outside it. Discovered scopes are narrowed to the + // registration, the same way a scope-limited first-party app narrows + // them; declared scopes the integration needs fail here with a clear + // error instead of an opaque `invalid_scope` from the server. + const registeredScopes = client.registeredScopes; + const requestedScopes = + registeredScopes === undefined + ? unboundedScopes + : scopePolicy.kind === "discover" + ? unboundedScopes.filter((scope) => registeredScopes.includes(scope)) + : unboundedScopes; + if (registeredScopes !== undefined) { + const unregistered = requestedScopes.filter((scope) => !registeredScopes.includes(scope)); + if (unregistered.length > 0) { + return yield* new OAuthStartError({ + message: + `OAuth app ${input.client} was registered without the scopes ` + + `${unregistered.join(", ")}, which ${input.integration} requests. ` + + `Reconnect to register an app with those scopes.`, + }); + } + } + // An explicitly scope-limited first-party app is an authorization // boundary, not picker decoration. Endpoint matching and provider // discovery can surface capabilities outside the registered app, so From daa3e1f2e2dbfa580af069a65347d55c8a9fcbe0 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:00:26 -0700 Subject: [PATCH 2/2] Record the scopes the server registered a DCR client for --- .changeset/dcr-registered-scopes.md | 2 +- .../cloud/drizzle/0023_blushing_thor_girl.sql | 1 - apps/cloud/drizzle/0023_wild_gwen_stacy.sql | 2 + apps/cloud/drizzle/meta/0023_snapshot.json | 8 ++- apps/cloud/drizzle/meta/_journal.json | 4 +- apps/cloud/src/db/executor-schema.ts | 1 + apps/local/drizzle/0007_lethal_nebula.sql | 1 - apps/local/drizzle/0007_mean_lucky_pierre.sql | 2 + apps/local/drizzle/meta/0007_snapshot.json | 9 +++- apps/local/drizzle/meta/_journal.json | 4 +- apps/local/src/db/executor-schema.ts | 1 + apps/local/src/db/sqlite-fumadb.ts | 6 +++ packages/core/sdk/src/core-schema.ts | 21 +++++--- packages/core/sdk/src/oauth-client.ts | 8 ++- packages/core/sdk/src/oauth-service.ts | 51 +++++++++++++------ 15 files changed, 86 insertions(+), 35 deletions(-) delete mode 100644 apps/cloud/drizzle/0023_blushing_thor_girl.sql create mode 100644 apps/cloud/drizzle/0023_wild_gwen_stacy.sql delete mode 100644 apps/local/drizzle/0007_lethal_nebula.sql create mode 100644 apps/local/drizzle/0007_mean_lucky_pierre.sql diff --git a/.changeset/dcr-registered-scopes.md b/.changeset/dcr-registered-scopes.md index 17944f30c9..9b05e3f5aa 100644 --- a/.changeset/dcr-registered-scopes.md +++ b/.changeset/dcr-registered-scopes.md @@ -2,4 +2,4 @@ "@executor-js/sdk": patch --- -Keep OAuth requests within the scopes a dynamically registered client registered for. DCR clients now persist their registered scopes (`oauth_client.origin_scopes`). Reuse picks a client only when its registration covers the requested scopes, so reconnecting after a server adds scopes registers a new client instead of pairing the old one with scopes the server refuses with `invalid_scope`. `oauth.start` narrows discovered scopes to the client's registration and fails with a clear error when declared scopes fall outside it. Clients registered before this release have no recorded scopes and are registered again on their next connect; existing connections keep refreshing through them. +Keep OAuth requests within the scopes a dynamically registered client was registered for. DCR clients now persist the scopes their registration asked for (`oauth_client.origin_requested_scopes`) and the scopes the server registered, from the registration response (`oauth_client.origin_scopes`, RFC 7591 §3.2.1). Reuse picks a client only when its registration covers the requested scopes, so reconnecting after a server adds scopes registers a new client instead of pairing the old one with scopes the server refuses with `invalid_scope`. `oauth.start` narrows discovered scopes to the server-registered set and fails with a clear error when declared scopes fall outside it. Clients registered before this release have no recorded scopes and are registered again on their next connect; existing connections keep refreshing through them. diff --git a/apps/cloud/drizzle/0023_blushing_thor_girl.sql b/apps/cloud/drizzle/0023_blushing_thor_girl.sql deleted file mode 100644 index 50ccd393e5..0000000000 --- a/apps/cloud/drizzle/0023_blushing_thor_girl.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "oauth_client" ADD COLUMN "origin_scopes" text; \ No newline at end of file diff --git a/apps/cloud/drizzle/0023_wild_gwen_stacy.sql b/apps/cloud/drizzle/0023_wild_gwen_stacy.sql new file mode 100644 index 0000000000..7fecf267db --- /dev/null +++ b/apps/cloud/drizzle/0023_wild_gwen_stacy.sql @@ -0,0 +1,2 @@ +ALTER TABLE "oauth_client" ADD COLUMN "origin_requested_scopes" text;--> statement-breakpoint +ALTER TABLE "oauth_client" ADD COLUMN "origin_scopes" text; \ No newline at end of file diff --git a/apps/cloud/drizzle/meta/0023_snapshot.json b/apps/cloud/drizzle/meta/0023_snapshot.json index 13d0374170..767950e852 100644 --- a/apps/cloud/drizzle/meta/0023_snapshot.json +++ b/apps/cloud/drizzle/meta/0023_snapshot.json @@ -1,5 +1,5 @@ { - "id": "f8434522-4dec-4ec3-a98d-7835dd9ed956", + "id": "dd2a6125-3d4a-4dd1-85db-3c6a9aa7c8d7", "prevId": "9d3bc667-63af-4a1b-99d9-b490ffcd80ef", "version": "7", "dialect": "postgresql", @@ -1140,6 +1140,12 @@ "primaryKey": false, "notNull": false }, + "origin_requested_scopes": { + "name": "origin_requested_scopes", + "type": "text", + "primaryKey": false, + "notNull": false + }, "origin_scopes": { "name": "origin_scopes", "type": "text", diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index 4422556d24..8ced0873c9 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -166,8 +166,8 @@ { "idx": 23, "version": "7", - "when": 1793066713689, - "tag": "0023_blushing_thor_girl", + "when": 1793077208148, + "tag": "0023_wild_gwen_stacy", "breakpoints": true } ] diff --git a/apps/cloud/src/db/executor-schema.ts b/apps/cloud/src/db/executor-schema.ts index c8a19ba62d..fe211ed23e 100644 --- a/apps/cloud/src/db/executor-schema.ts +++ b/apps/cloud/src/db/executor-schema.ts @@ -106,6 +106,7 @@ export const oauth_client = pgTable( origin_integration: text("origin_integration"), origin_issuer: text("origin_issuer"), origin_redirect_uri: text("origin_redirect_uri"), + origin_requested_scopes: text("origin_requested_scopes"), origin_scopes: text("origin_scopes"), created_at: timestamp("created_at").notNull(), row_id: varchar("row_id", { length: 255 }) diff --git a/apps/local/drizzle/0007_lethal_nebula.sql b/apps/local/drizzle/0007_lethal_nebula.sql deleted file mode 100644 index 52cbbd2750..0000000000 --- a/apps/local/drizzle/0007_lethal_nebula.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE `oauth_client` ADD `origin_scopes` text; \ No newline at end of file diff --git a/apps/local/drizzle/0007_mean_lucky_pierre.sql b/apps/local/drizzle/0007_mean_lucky_pierre.sql new file mode 100644 index 0000000000..fe1ccb504e --- /dev/null +++ b/apps/local/drizzle/0007_mean_lucky_pierre.sql @@ -0,0 +1,2 @@ +ALTER TABLE `oauth_client` ADD `origin_requested_scopes` text;--> statement-breakpoint +ALTER TABLE `oauth_client` ADD `origin_scopes` 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 index 8151cdefff..161d1c269a 100644 --- a/apps/local/drizzle/meta/0007_snapshot.json +++ b/apps/local/drizzle/meta/0007_snapshot.json @@ -1,7 +1,7 @@ { "version": "6", "dialect": "sqlite", - "id": "ccdfb606-c5fd-4f3c-8296-2667eb31cd1a", + "id": "8037cf3d-eb52-4f29-aa94-63b614909837", "prevId": "89a4fd1b-f0f6-4482-a991-0db78c859f76", "tables": { "blob": { @@ -479,6 +479,13 @@ "notNull": false, "autoincrement": false }, + "origin_requested_scopes": { + "name": "origin_requested_scopes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, "origin_scopes": { "name": "origin_scopes", "type": "text", diff --git a/apps/local/drizzle/meta/_journal.json b/apps/local/drizzle/meta/_journal.json index 2933003906..4711a85e8a 100644 --- a/apps/local/drizzle/meta/_journal.json +++ b/apps/local/drizzle/meta/_journal.json @@ -54,8 +54,8 @@ { "idx": 7, "version": "6", - "when": 1793066713918, - "tag": "0007_lethal_nebula", + "when": 1793077208384, + "tag": "0007_mean_lucky_pierre", "breakpoints": true } ] diff --git a/apps/local/src/db/executor-schema.ts b/apps/local/src/db/executor-schema.ts index 0b087b714e..00a48ffdb4 100644 --- a/apps/local/src/db/executor-schema.ts +++ b/apps/local/src/db/executor-schema.ts @@ -80,6 +80,7 @@ export const oauth_client = sqliteTable( origin_integration: text("origin_integration"), origin_issuer: text("origin_issuer"), origin_redirect_uri: text("origin_redirect_uri"), + origin_requested_scopes: text("origin_requested_scopes"), origin_scopes: text("origin_scopes"), created_at: integer("created_at").notNull(), row_id: text("row_id").primaryKey().notNull(), diff --git a/apps/local/src/db/sqlite-fumadb.ts b/apps/local/src/db/sqlite-fumadb.ts index 1b019ce476..2b0426f2d3 100644 --- a/apps/local/src/db/sqlite-fumadb.ts +++ b/apps/local/src/db/sqlite-fumadb.ts @@ -105,6 +105,12 @@ export const createSqliteFumaDb = async ( ) { await client.execute("ALTER TABLE oauth_client ADD COLUMN origin_redirect_uri TEXT"); } + if ( + oauthClientColumns.rows.length > 0 && + !oauthClientColumns.rows.some((column) => column["name"] === "origin_requested_scopes") + ) { + await client.execute("ALTER TABLE oauth_client ADD COLUMN origin_requested_scopes TEXT"); + } if ( oauthClientColumns.rows.length > 0 && !oauthClientColumns.rows.some((column) => column["name"] === "origin_scopes") diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index 5c232fffa9..64d87fb650 100644 --- a/packages/core/sdk/src/core-schema.ts +++ b/packages/core/sdk/src/core-schema.ts @@ -299,14 +299,19 @@ 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 space-delimited scopes a DCR client registered for (RFC 7591 - // `scope`); empty when the registration sent none. A server may refuse - // an authorize request for a scope the client was not registered for, so - // reuse only picks a client whose registration covers the request, and - // start never asks for more. Null for manual clients and for DCR rows - // written before this column existed: unlike the redirect URI, a missing - // record is not treated as covering, because a server that has since - // added scopes rejects every reconnect through the old client. + // The space-delimited scopes a DCR registration asked for (RFC 7591 + // `scope`); empty when it sent none. Reuse picks a client only when the + // scopes it asked for cover the request, so a server that has since + // added scopes gets a new registration rather than refusing every + // reconnect through the old client. Null for manual clients and for DCR + // rows written before this column existed: unlike the redirect URI, a + // missing record is not treated as covering. + origin_requested_scopes: nullableTextColumn("origin_requested_scopes"), + // The space-delimited scopes the server registered the client for, from + // the registration response's `scope` (RFC 7591 §3.2.1; the server may + // narrow what was asked for). A server may refuse an authorize request + // for a scope outside them, so start never asks for more. Null when the + // response did not say, for manual clients and for older rows. origin_scopes: nullableTextColumn("origin_scopes"), created_at: dateColumn("created_at"), }, diff --git a/packages/core/sdk/src/oauth-client.ts b/packages/core/sdk/src/oauth-client.ts index 6b322aac7c..42e4cbb6fb 100644 --- a/packages/core/sdk/src/oauth-client.ts +++ b/packages/core/sdk/src/oauth-client.ts @@ -283,8 +283,12 @@ export type CreateOAuthClientInput = OAuthClient & { * registration). Ignored for manual clients. */ readonly originRedirectUri?: string | null; /** The scopes a DCR registration sent as the client's `scope`. Persisted so - * reuse and start never ask the server for scopes the client was not - * registered for. Ignored for manual clients. */ + * reuse can tell whether a registration covers a later request. Ignored for + * manual clients. */ + readonly originRequestedScopes?: readonly string[] | null; + /** The scopes the server registered the client for, from the registration + * response; null when it did not say. Persisted so start never asks for a + * scope outside them. Ignored for manual clients. */ readonly originScopes?: readonly string[] | null; }; diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index cdb62e23ce..8a26778c20 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -571,8 +571,9 @@ interface LoadedOAuthClient { readonly resource: string | null; readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod; readonly tokenRequestFormat?: "form" | "json"; - /** The scopes a DCR client registered for, when its registration recorded - * any. A server may refuse an authorize request for a scope outside them. */ + /** The scopes the server registered a DCR client for, when its registration + * response said. A server may refuse an authorize request for a scope + * outside them. */ readonly registeredScopes?: readonly string[]; } @@ -1053,6 +1054,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { input.origin?.kind === "dynamic_client_registration" ? (input.originRedirectUri ?? null) : null, + origin_requested_scopes: + input.origin?.kind === "dynamic_client_registration" && + input.originRequestedScopes != null + ? input.originRequestedScopes.join(" ") + : null, origin_scopes: input.origin?.kind === "dynamic_client_registration" && input.originScopes != null ? input.originScopes.join(" ") @@ -1300,7 +1306,10 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { /** Redirect URI the candidate registered with the AS; null for rows * predating the column. */ readonly redirectUri: string | null; - /** Scopes the candidate registered for; null for rows predating the column. */ + /** Scopes the candidate's registration asked for; null for rows predating + * the column. */ + readonly requestedScopes: readonly string[] | null; + /** Scopes the server registered the candidate for; null when unknown. */ readonly scopes: readonly string[] | null; }; @@ -1356,6 +1365,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { resource: row.resource == null ? null : String(row.resource), redirectUri: row.origin_redirect_uri == null ? null : String(row.origin_redirect_uri), + requestedScopes: parseStoredScopes(row.origin_requested_scopes), scopes: parseStoredScopes(row.origin_scopes), createdAt: candidateCreatedAt(row.created_at), }, @@ -1373,10 +1383,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { a.createdAt - b.createdAt || (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0), ) .map( - ({ slug, resource, redirectUri, scopes }): DcrReuseCandidate => ({ + ({ slug, resource, redirectUri, requestedScopes, scopes }): DcrReuseCandidate => ({ slug, resource, redirectUri, + requestedScopes, scopes, }), ); @@ -1408,17 +1419,22 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { if (hasExplicitRedirectUri) return false; return candidate.redirectUri === null || flowRedirectUri === null; }; - // A client is reusable only when its registration covers every scope this - // one asks for: a server may refuse an authorize request for a scope the - // client was not registered for, and a server that has since added scopes - // would otherwise reject every reconnect through the old client. A row - // with no recorded scopes is not proof of coverage. + // A client is reusable only when its registration asked for, or was + // granted, every scope this one asks for: a server may refuse an + // authorize request for a scope the client was not registered for, and a + // server that has since added scopes would otherwise reject every + // reconnect through the old client. Coverage counts what the + // registration ASKED for, so a server that narrows registrations is not + // asked again on every reconnect for scopes it will never register. A + // row with no recorded request is not proof of coverage. const registrationScopes = dcrRegistrationScopes(input); const scopesCovered = (candidate: DcrReuseCandidate): boolean => { - const registered = candidate.scopes; if (registrationScopes.length === 0) return true; - return ( - registered !== null && registrationScopes.every((scope) => registered.includes(scope)) + const requested = candidate.requestedScopes; + if (requested === null) return false; + const registered = candidate.scopes ?? []; + return registrationScopes.every( + (scope) => requested.includes(scope) || registered.includes(scope), ); }; const reusableFor = (candidate: DcrReuseCandidate): boolean => @@ -1550,7 +1566,11 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }, originIssuer: issuer, originRedirectUri: flowRedirectUri, - originScopes: registrationScopes, + originRequestedScopes: registrationScopes, + // RFC 7591 §3.2.1: the response carries what the server registered, + // which may differ from what was asked for. + originScopes: + information.scope === undefined ? null : information.scope.split(/\s+/).filter(Boolean), }); return slug; }); @@ -1872,9 +1892,8 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { if (unregistered.length > 0) { return yield* new OAuthStartError({ message: - `OAuth app ${input.client} was registered without the scopes ` + - `${unregistered.join(", ")}, which ${input.integration} requests. ` + - `Reconnect to register an app with those scopes.`, + `The authorization server registered OAuth app ${input.client} without ` + + `the scopes ${unregistered.join(", ")}, which ${input.integration} requests.`, }); } }