diff --git a/.changeset/oauth-client-extra-token-params.md b/.changeset/oauth-client-extra-token-params.md new file mode 100644 index 0000000000..1a22299aa7 --- /dev/null +++ b/.changeset/oauth-client-extra-token-params.md @@ -0,0 +1,7 @@ +--- +"executor": patch +--- + +Accept extra token-request parameters on a registered OAuth client. + +Some providers need provider-shaped, non-secret parameters on every token request that no grant field carries — Auth0's Management API, for example, only issues a `client_credentials` token when the request names the API in `audience`. A registered (BYO) OAuth app can now carry an `audience`-style map (`tokenParams`), which executor sends on the client-credentials exchange, the authorization-code exchange, and every refresh. The map is registered through all surfaces that register a client — the web "Register OAuth app" form (new optional "Extra token parameters" JSON field), `oauth.clients.create`, and `oauth.clients.createHandoff` deep links — and is rejected if any name would shadow an OAuth protocol parameter (`grant_type`, `client_id`, `client_secret`, `scope`, `resource`, `code`, `code_verifier`, `redirect_uri`, `refresh_token`). diff --git a/apps/cloud/drizzle/0023_common_the_executioner.sql b/apps/cloud/drizzle/0023_common_the_executioner.sql new file mode 100644 index 0000000000..b9230a773c --- /dev/null +++ b/apps/cloud/drizzle/0023_common_the_executioner.sql @@ -0,0 +1 @@ +ALTER TABLE "oauth_client" ADD COLUMN "token_params" json; \ No newline at end of file diff --git a/apps/cloud/drizzle/meta/0021_snapshot.json b/apps/cloud/drizzle/meta/0023_snapshot.json similarity index 99% rename from apps/cloud/drizzle/meta/0021_snapshot.json rename to apps/cloud/drizzle/meta/0023_snapshot.json index cff207f796..e1fd448514 100644 --- a/apps/cloud/drizzle/meta/0021_snapshot.json +++ b/apps/cloud/drizzle/meta/0023_snapshot.json @@ -1,6 +1,6 @@ { - "id": "6b0d43ca-a04b-4dc8-b637-982f7ebbc3b8", - "prevId": "88f2845b-be28-4ad3-92b2-2cac819478e5", + "id": "5b4f48bf-9d1d-4d9c-8bb0-f03d8cfef94b", + "prevId": "9d3bc667-63af-4a1b-99d9-b490ffcd80ef", "version": "7", "dialect": "postgresql", "tables": { @@ -296,6 +296,12 @@ "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", @@ -322,7 +328,7 @@ "checkConstraints": { "org_moved_to_v2_state_check": { "name": "org_moved_to_v2_state_check", - "value": "\"org_moved_to_v2\".\"state\" in ('moving', 'moved')" + "value": "\"org_moved_to_v2\".\"state\" in ('moving', 'moved', 'rolled_back')" } }, "isRLSEnabled": false @@ -1110,6 +1116,12 @@ "primaryKey": false, "notNull": false }, + "token_params": { + "name": "token_params", + "type": "json", + "primaryKey": false, + "notNull": false + }, "origin_kind": { "name": "origin_kind", "type": "text", diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index f751865445..ee7dc311ec 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": 1792999530947, + "tag": "0023_common_the_executioner", + "breakpoints": true } ] } diff --git a/apps/cloud/src/db/executor-schema.ts b/apps/cloud/src/db/executor-schema.ts index 0db709b884..85ea90deed 100644 --- a/apps/cloud/src/db/executor-schema.ts +++ b/apps/cloud/src/db/executor-schema.ts @@ -102,6 +102,9 @@ export const oauth_client = pgTable( credential_write: json("credential_write"), token_endpoint_auth_method: text("token_endpoint_auth_method"), resource: text("resource"), + // Provider-shaped, NON-secret extra parameters sent on EVERY token + // request this client makes. Null in rows registered without one. + token_params: json("token_params"), origin_kind: text("origin_kind"), origin_integration: text("origin_integration"), origin_issuer: text("origin_issuer"), diff --git a/apps/local/drizzle/0007_fearless_lifeguard.sql b/apps/local/drizzle/0007_fearless_lifeguard.sql new file mode 100644 index 0000000000..ec22cf5af1 --- /dev/null +++ b/apps/local/drizzle/0007_fearless_lifeguard.sql @@ -0,0 +1 @@ +ALTER TABLE `oauth_client` ADD `token_params` 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..2e86d89d52 --- /dev/null +++ b/apps/local/drizzle/meta/0007_snapshot.json @@ -0,0 +1,955 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a5a576fa-8d03-4b2f-9a54-cc9a6cf8e774", + "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 + }, + "token_params": { + "name": "token_params", + "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 + }, + "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..e8e4d074a8 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": 1792998237334, + "tag": "0007_fearless_lifeguard", + "breakpoints": true } ] } diff --git a/apps/local/src/db/executor-schema.ts b/apps/local/src/db/executor-schema.ts index a7f6ceb48e..0dbf3c21df 100644 --- a/apps/local/src/db/executor-schema.ts +++ b/apps/local/src/db/executor-schema.ts @@ -76,6 +76,9 @@ export const oauth_client = sqliteTable( credential_write: text("credential_write"), token_endpoint_auth_method: text("token_endpoint_auth_method"), resource: text("resource"), + // Provider-shaped, NON-secret extra parameters sent on EVERY token + // request this client makes. Null in rows registered without one. + token_params: text("token_params"), origin_kind: text("origin_kind"), origin_integration: text("origin_integration"), origin_issuer: text("origin_issuer"), diff --git a/packages/core/api/src/handlers/oauth.ts b/packages/core/api/src/handlers/oauth.ts index 74aa01988f..b5a27e07e6 100644 --- a/packages/core/api/src/handlers/oauth.ts +++ b/packages/core/api/src/handlers/oauth.ts @@ -112,6 +112,7 @@ export const OAuthHandlers = HttpApiBuilder.group(ExecutorApi, "oauth", (handler clientSecret: payload.clientSecret, tokenEndpointAuthMethod: payload.tokenEndpointAuthMethod, resource: payload.resource ?? null, + tokenParams: payload.tokenParams, origin: { kind: "manual", integration: payload.originIntegration ?? null }, }); return { client }; diff --git a/packages/core/api/src/oauth/api.ts b/packages/core/api/src/oauth/api.ts index 5d0eee3d41..1ed4c99dbf 100644 --- a/packages/core/api/src/oauth/api.ts +++ b/packages/core/api/src/oauth/api.ts @@ -28,6 +28,7 @@ import { OAuthSessionNotFoundError, OAuthStartError, OAuthState, + OAuthTokenParamsSchema, OrgWriteDeniedError, Owner, ProviderKey, @@ -70,6 +71,10 @@ const CreateClientPayload = Schema.Struct({ clientSecret: Schema.String, tokenEndpointAuthMethod: Schema.optional(TokenEndpointAuthMethodSchema), resource: Schema.optional(Schema.NullOr(Schema.String)), + /** Provider-shaped, NON-secret extra parameters (Auth0's `audience`, for + * example) sent on every token request this client makes. OAuth protocol + * parameter names are rejected at registration. */ + tokenParams: Schema.optional(OAuthTokenParamsSchema), /** 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)), @@ -120,6 +125,8 @@ const OAuthClientSummaryResponse = Schema.Struct({ resource: Schema.optional(Schema.NullOr(Schema.String)), clientId: Schema.String, tokenEndpointAuthMethod: Schema.optional(TokenEndpointAuthMethodSchema), + /** Omitted for rows registered without extra token parameters. */ + tokenParams: Schema.optional(OAuthTokenParamsSchema), origin: Schema.Union([ Schema.Struct({ kind: Schema.Literal("manual") }), Schema.Struct({ diff --git a/packages/core/sdk/src/client.ts b/packages/core/sdk/src/client.ts index ce245ed3ad..774deea3d4 100644 --- a/packages/core/sdk/src/client.ts +++ b/packages/core/sdk/src/client.ts @@ -178,6 +178,9 @@ export interface IntegrationAccountHandoff { readonly clientId?: string; readonly authorizationUrl?: string; readonly tokenUrl?: string; + /** Provider-shaped, NON-secret token-request parameters pre-filled onto + * the registration form (Auth0's `audience`, for example). */ + readonly tokenParams?: Record; /** RFC 8707 resource indicator. On a reconnect handoff this is the STORED * client's value, and an EXPLICIT null means the stored client was * registered WITHOUT a resource indicator — that absence must survive a diff --git a/packages/core/sdk/src/core-schema.ts b/packages/core/sdk/src/core-schema.ts index 8014584695..e411d198ae 100644 --- a/packages/core/sdk/src/core-schema.ts +++ b/packages/core/sdk/src/core-schema.ts @@ -278,6 +278,11 @@ export const coreTables = defineTables({ // re-minted access token stays bound to the same resource. Null when the // provider doesn't use resource indicators. resource: nullableTextColumn("resource"), + // Provider-shaped, NON-secret extra parameters sent on EVERY token + // request this client makes (Auth0's `audience`, for example). Reserved + // OAuth protocol names are rejected on write and again on read; null in + // rows registered without one. + token_params: nullableJsonColumn("token_params"), // Where this oauth_client came from. Null in old databases is treated as // "manual" by the service layer. origin_kind: nullableTextColumn("origin_kind"), diff --git a/packages/core/sdk/src/core-tools.ts b/packages/core/sdk/src/core-tools.ts index 461e04d8a9..21831c5430 100644 --- a/packages/core/sdk/src/core-tools.ts +++ b/packages/core/sdk/src/core-tools.ts @@ -242,6 +242,10 @@ const OAuthCreateClientInput = Schema.Struct({ grant: OAuthGrantSchema, clientId: Schema.String, resource: Schema.optional(Schema.NullOr(Schema.String)), + /** Provider-shaped, NON-secret extra parameters (Auth0's `audience`, for + * example) sent on every token request this client makes. OAuth protocol + * parameter names are rejected at registration. */ + tokenParams: Schema.optional(Schema.NullOr(Schema.Record(Schema.String, Schema.String))), /** Integration whose connect dialog registered this manual app. Recorded so * the picker can match it by intent (exact) instead of by root domain. */ originIntegration: Schema.optional(Schema.NullOr(Schema.String)), @@ -259,6 +263,10 @@ const OAuthCreateClientHandoffInput = Schema.Struct({ authorizationUrl: Schema.optional(Schema.String), tokenUrl: Schema.optional(Schema.String), resource: Schema.optional(Schema.NullOr(Schema.String)), + /** Provider-shaped, NON-secret extra parameters (Auth0's `audience`, for + * example) the form pre-fills; they are sent on every token request the + * registered app makes. Never a secret. */ + tokenParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), label: Schema.optional(Schema.String), }); const OAuthCreateClientHandoffOutput = Schema.Struct({ @@ -525,6 +533,10 @@ const oauthClientCreateHandoffUrl = ( if (input.authorizationUrl !== undefined) search.set("authorizationUrl", input.authorizationUrl); if (input.tokenUrl !== undefined) search.set("tokenUrl", input.tokenUrl); if (input.resource != null && input.resource.length > 0) search.set("resource", input.resource); + // Serialized as JSON because it is a map, pre-filled into the form's extra + // token-parameter field. NON-secret by contract — provider-shaped parameters + // (Auth0's `audience`, for example) are public request data. + if (input.tokenParams !== undefined) search.set("tokenParams", JSON.stringify(input.tokenParams)); if (input.label !== undefined) search.set("label", input.label); const orgPrefix = orgSlug !== undefined && orgSlug.length > 0 ? `/${orgSlug}` : ""; const path = `${orgPrefix}/integrations/${encodeURIComponent(input.integration)}?${search.toString()}`; @@ -823,6 +835,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { // `oauth.clients.createHandoff`. clientSecret: "", resource: input.resource ?? null, + tokenParams: input.tokenParams ?? undefined, origin: { kind: "manual", integration: diff --git a/packages/core/sdk/src/executor.test.ts b/packages/core/sdk/src/executor.test.ts index fb2835a529..3607ad84da 100644 --- a/packages/core/sdk/src/executor.test.ts +++ b/packages/core/sdk/src/executor.test.ts @@ -693,6 +693,10 @@ describe("createExecutor", () => { clientId: "test-client", clientSecret: "test-secret", resource: server.resourceUrl, + // Auth0's Management API only issues a client-credentials token that + // names the API — the parameter rides on the mint (and, through the + // same stored row, on the proactive re-mint at refresh time). + tokenParams: { audience: "https://api.example.com/" }, }); expect(registered).toEqual(client); @@ -725,6 +729,9 @@ describe("createExecutor", () => { ); expect(tokenRequest).toBeDefined(); expect(new URLSearchParams(tokenRequest!.body).get("resource")).toBe(server.resourceUrl); + expect(new URLSearchParams(tokenRequest!.body).get("audience")).toBe( + "https://api.example.com/", + ); const out = yield* executor.execute(ToolAddress.make("tools.demo.org.oauth.run"), {}); expect(out).toEqual({ ran: "run" }); diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index ccdd37face..4f3afcce32 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -149,6 +149,7 @@ import { } from "./oauth-service"; import { isFirstPartyOAuthClientSlug, + parseStoredOAuthTokenParams, parseStoredTokenEndpointAuthMethod, type OAuthService, type TokenEndpointAuthMethod, @@ -2372,6 +2373,10 @@ export const createExecutor = >; } /** What drove a refresh: the pre-call expiry check (`proactive`), or an @@ -2764,6 +2769,16 @@ export const createExecutor = value === "body" || value === "basic" || value === "basic_raw"; +/** Extra, provider-selected parameters carried on EVERY token request a + * registered client makes (the client-credentials exchange, the code + * exchange, and refresh). Auth0's Management API only issues a + * client-credentials token when the request names the API in `audience` — a + * parameter that is entirely provider-shaped and NON-secret, so any + * registered OAuth app can carry it. + * + * `OAuth` protocol parameter names are rejected at registration and again + * before every request, so a stored map can never shadow what the grant + * itself sends (`grant_type`, `client_id`, `client_secret`, `scope`, + * `resource`, `code`, `code_verifier`, `redirect_uri`, `refresh_token`). */ +export const OAuthTokenParamsSchema = Schema.Record(Schema.String, Schema.String).annotate({ + identifier: "OAuthTokenParams", + description: "Provider-shaped, NON-secret extra parameters sent on this client's token requests.", +}); + +export type OAuthTokenParams = typeof OAuthTokenParamsSchema.Type; + +const TOKEN_PROTOCOL_PARAMS = new Set([ + "grant_type", + "client_id", + "client_secret", + "scope", + "resource", + "code", + "code_verifier", + "redirect_uri", + "refresh_token", +]); + +export const isReservedOAuthTokenParam = (name: string): boolean => + TOKEN_PROTOCOL_PARAMS.has(name.toLowerCase()); + +/** The first reserved name in `params`, when one exists — the message a + * registration surface reports. Order-independent: reserved keys are refused + * regardless of their value, because an OAuth protocol parameter is not + * provider-selected data at all. */ +export const reservedOAuthTokenParamIn = ( + params: Readonly> | undefined, +): string | null => { + if (params === undefined) return null; + for (const name of Object.keys(params)) { + if (isReservedOAuthTokenParam(name)) return name; + } + return null; +}; + +/** Decode a nullable stored `token_params` value into the contract shape. + * `undefined` means the row carries none (legacy rows, or an explicit null); + * `null` means the row holds a NON-conforming value — the caller fails loudly + * the same way it does for an unknown token-endpoint auth method, because this + * map is sent verbatim on every token request. */ +export const parseStoredOAuthTokenParams = ( + value: unknown, +): OAuthTokenParams | undefined | null => { + if (value == null) return undefined; + if (typeof value !== "object" || Array.isArray(value)) return null; + for (const [name, paramValue] of Object.entries(value)) { + if (typeof paramValue !== "string") return null; + if (isReservedOAuthTokenParam(name)) return null; + } + return value as OAuthTokenParams; +}; + /** Decode a nullable stored value. `undefined` is the legacy/default body * method; `null` means the row contains an invalid non-null value. */ export const parseStoredTokenEndpointAuthMethod = ( @@ -139,6 +203,10 @@ 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; + /** Provider-shaped, NON-secret parameters sent on every token request this + * client makes (Auth0's `audience`, for example). Protocol parameter names + * are rejected at registration. Omitted when the client carries none. */ + readonly tokenParams?: Readonly>; } export type OAuthClientOrigin = @@ -298,6 +366,8 @@ export interface OAuthClientSummary { readonly clientId: string; /** Omitted for legacy rows, which use client_secret_post. */ readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod; + /** Omitted for rows registered without extra token parameters. */ + readonly tokenParams?: Readonly>; readonly origin: OAuthClientOrigin; } diff --git a/packages/core/sdk/src/oauth-flow.test.ts b/packages/core/sdk/src/oauth-flow.test.ts index 281d6062ce..d88a8c7e01 100644 --- a/packages/core/sdk/src/oauth-flow.test.ts +++ b/packages/core/sdk/src/oauth-flow.test.ts @@ -16,6 +16,7 @@ import { } from "./ids"; import { authToolFailure } from "./auth-tool-failure"; import { createExecutor } from "./executor"; +import { StorageError } from "./fuma-runtime"; import { decodeOAuthCallbackState } from "./oauth"; import { OAuthStartError } from "./oauth-client"; import { missingGrantedOAuthScopes } from "./oauth-service"; @@ -968,6 +969,81 @@ describe("oauth.start / oauth.complete", () => { ), ); + it.effect( + "client_credentials start sends the registered client's extra token parameters (Auth0 audience)", + () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); + const { executor } = yield* makeTestWorkspaceHarness({ + plugins, + redirectUri: null, + }); + yield* executor.acme.seed(); + + yield* executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: server.authorizationEndpoint, + tokenUrl: server.tokenEndpoint, + grant: "client_credentials", + clientId: "test-client", + clientSecret: "test-secret", + // Auth0's Management API only issues a token whose `aud` names the + // API — the parameter is provider-shaped, not a protocol one. + tokenParams: { audience: "https://api.example.com/" }, + }); + + const started = yield* executor.oauth.start({ + owner: "org", + client: CLIENT, + clientOwner: "org", + name: ConnectionName.make("cc"), + integration: INTEG, + template: TEMPLATE, + }); + expect(started.status).toBe("connected"); + + // The token endpoint saw the provider-shaped parameter on the mint. + const tokenCalls = (yield* server.requests).filter( + (request) => request.path === "/token", + ); + expect(tokenCalls.length).toBeGreaterThan(0); + expect( + tokenCalls.every( + (request) => + new URLSearchParams(request.body).get("audience") === "https://api.example.com/", + ), + ).toBe(true); + }), + ), + ); + + it.effect("createClient refuses token parameters that shadow protocol parameters", () => + Effect.scoped( + Effect.gen(function* () { + const { executor } = yield* makeTestWorkspaceHarness({ plugins }); + const error = yield* Effect.flip( + executor.oauth.createClient({ + owner: "org", + slug: CLIENT, + authorizationUrl: "https://as.example.com/authorize", + tokenUrl: "https://as.example.com/token", + grant: "client_credentials", + clientId: "test-client", + clientSecret: "test-secret", + tokenParams: { grant_type: "password" }, + }), + ); + // `StorageError` carries a typed `message`; the tagged-error cast + // narrows the union so the read below is on a typed failure. + expect(Predicate.isTagged("StorageError")(error)).toBe(true); + const storageError = error as StorageError; + expect(storageError.message).toContain("protocol parameter"); + }), + ), + ); + it.effect("complete with an unknown state fails OAuthSessionNotFoundError", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/core/sdk/src/oauth-helpers.test.ts b/packages/core/sdk/src/oauth-helpers.test.ts index 92bc623d06..425c5c44a9 100644 --- a/packages/core/sdk/src/oauth-helpers.test.ts +++ b/packages/core/sdk/src/oauth-helpers.test.ts @@ -1333,6 +1333,25 @@ describe("exchangeAuthorizationCode", () => { }), ), ); + + it.effect("sends the registered client's extra token parameters on code exchange", () => + withTokenEndpoint(tokenResponse(validCodeBody), ({ tokenUrl, calls }) => + Effect.gen(function* () { + yield* exchangeAuthorizationCode({ + tokenUrl, + clientId: "cid", + clientSecret: "csecret", + redirectUrl: "https://app.example.com/cb", + codeVerifier: "verifier", + code: "abc", + extraTokenParams: { audience: "https://api.example.com/" }, + }); + const body = (yield* calls)[0]!.body; + expect(body.get("grant_type")).toBe("authorization_code"); + expect(body.get("audience")).toBe("https://api.example.com/"); + }), + ), + ); }); describe("exchangeClientCredentials", () => { @@ -1393,6 +1412,46 @@ describe("exchangeClientCredentials", () => { expect(JSON.stringify(exit.cause)).toContain("Token URL must use https: or loopback http:"); }), ); + + it.effect("sends the registered client's extra token parameters (Auth0 audience)", () => + withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) => + Effect.gen(function* () { + yield* exchangeClientCredentials({ + tokenUrl, + clientId: "cid", + clientSecret: "secret", + scopes: ["read"], + extraTokenParams: { audience: "https://api.example.com/" }, + }); + + const body = (yield* calls)[0]!.body; + expect(body.get("grant_type")).toBe("client_credentials"); + expect(body.get("scope")).toBe("read"); + expect(body.get("audience")).toBe("https://api.example.com/"); + }), + ), + ); + + it.effect("refuses stored parameters that shadow OAuth protocol parameters", () => + withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) => + Effect.gen(function* () { + const exit = yield* Effect.exit( + exchangeClientCredentials({ + tokenUrl, + clientId: "cid", + clientSecret: "secret", + extraTokenParams: { grant_type: "password" }, + }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (!Exit.isFailure(exit)) return; + expect(JSON.stringify(exit.cause)).toContain("protocol parameter"); + // Nothing was sent: the grant the endpoint received is the one the + // protocol sent, not the stored shadow. + expect(yield* calls).toEqual([]); + }), + ), + ); }); describe("refreshAccessToken", () => { @@ -1485,6 +1544,42 @@ describe("refreshAccessToken", () => { ), ); + it.effect("sends the registered client's extra token parameters on refresh", () => + withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) => + Effect.gen(function* () { + yield* refreshAccessToken({ + tokenUrl, + clientId: "cid", + clientSecret: "csecret", + refreshToken: "old", + extraTokenParams: { audience: "https://api.example.com/" }, + }); + expect((yield* calls)[0]!.body.get("audience")).toBe("https://api.example.com/"); + }), + ), + ); + + it.effect("sends extra token parameters on JSON-format refresh too", () => + withTokenEndpoint(tokenResponse(validRefreshBody), ({ tokenUrl, calls }) => + Effect.gen(function* () { + yield* refreshAccessToken({ + tokenUrl, + clientId: "cid", + clientSecret: "csecret", + refreshToken: "old", + requestFormat: "json", + extraTokenParams: { audience: "https://api.example.com/" }, + }); + const body = (yield* calls)[0]!.jsonBody; + expect(body).toMatchObject({ + grant_type: "refresh_token", + refresh_token: "old", + audience: "https://api.example.com/", + }); + }), + ), + ); + // Railway (issue #1969, 2026-09-09) refuses any scope-bearing refresh with // `invalid_scope: refresh token missing requested scope` even though echoing // the grant's own scope is legal under RFC 6749 §6. Without the fallback a diff --git a/packages/core/sdk/src/oauth-helpers.ts b/packages/core/sdk/src/oauth-helpers.ts index 178beaa277..cc466aae78 100644 --- a/packages/core/sdk/src/oauth-helpers.ts +++ b/packages/core/sdk/src/oauth-helpers.ts @@ -20,6 +20,7 @@ import { Data, Effect, Option, Predicate, Schema } from "effect"; import * as oauth from "oauth4webapi"; import type { SubjectTokenType, TokenEndpointAuthMethod } from "./oauth-client"; +import { isReservedOAuthTokenParam } from "./oauth-client"; // --------------------------------------------------------------------------- // Errors @@ -1223,6 +1224,40 @@ const processTokenEndpointResponse = async ( : token; }; +// --------------------------------------------------------------------------- +// Extra token-request parameters (provider-shaped, non-secret) +// --------------------------------------------------------------------------- + +/** Refuse a client's extra token parameters that would shadow what the grant + * protocol itself controls. The registration surface rejects these names + * already; this is the same verdict on the request path, so no code path + * (today's or a future one) can send a stored key as a protocol parameter. */ +export const assertExtraTokenParamsValid = ( + extra: Readonly> | undefined, +): void => { + for (const name of Object.keys(extra ?? {})) { + if (isReservedOAuthTokenParam(name)) { + // Thrown INSIDE the callers' `Effect.tryPromise` bodies, whose catch + // normalizes it into the helpers' declared `OAuth2Error` failure channel. + // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: typed-error throw normalized to a typed failure by the surrounding Effect.tryPromise catch + throw new OAuth2Error({ + message: `Stored token parameter "${name}" is an OAuth protocol parameter, so it cannot be overridden.`, + }); + } + } +}; + +/** Merge a client's extra token parameters into a request's form parameters. */ +export const appendExtraTokenParams = ( + params: URLSearchParams, + extra: Readonly> | undefined, +): void => { + assertExtraTokenParamsValid(extra); + for (const [name, value] of Object.entries(extra ?? {})) { + params.set(name, value); + } +}; + // --------------------------------------------------------------------------- // Exchange authorization code → tokens // --------------------------------------------------------------------------- @@ -1244,6 +1279,10 @@ export type ExchangeAuthorizationCodeInput = { * the token request when the client knows the resource it intends * to call. */ readonly resource?: string; + /** Provider-shaped extra parameters sent on this client's token requests + * (Auth0's `audience`, for example). Reserved OAuth protocol names are + * rejected before anything is sent. */ + readonly extraTokenParams?: Readonly>; readonly timeoutMs?: number; readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; readonly fetch?: typeof globalThis.fetch; @@ -1325,6 +1364,7 @@ export const exchangeAuthorizationCode = ( if (input.resource) { params.set("resource", input.resource); } + appendExtraTokenParams(params, input.extraTokenParams); const response = input.requestFormat === "json" ? await jsonTokenEndpointRequest({ @@ -1378,6 +1418,10 @@ export type ExchangeClientCredentialsInput = { /** RFC 8707 Resource Indicator. MCP Authorization 2025-06-18 requires this * on token requests when the client knows the protected resource. */ readonly resource?: string; + /** Provider-shaped extra parameters sent on this client's token requests + * (Auth0's `audience`, for example). Reserved OAuth protocol names are + * rejected before anything is sent. */ + readonly extraTokenParams?: Readonly>; readonly timeoutMs?: number; readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; readonly fetch?: typeof globalThis.fetch; @@ -1401,6 +1445,7 @@ export const exchangeClientCredentials = ( if (input.resource) { params.set("resource", input.resource); } + appendExtraTokenParams(params, input.extraTokenParams); const response = await oauth.clientCredentialsGrantRequest( as, client, @@ -1448,6 +1493,10 @@ export type RefreshAccessTokenInput = { * refresh requests so the new access token's audience is bound to * the same resource. */ readonly resource?: string; + /** Provider-shaped extra parameters sent on this client's token requests + * (Auth0's `audience`, for example). Reserved OAuth protocol names are + * rejected before anything is sent. */ + readonly extraTokenParams?: Readonly>; readonly timeoutMs?: number; readonly endpointUrlPolicy?: OAuthEndpointUrlPolicy; readonly fetch?: typeof globalThis.fetch; @@ -1475,6 +1524,7 @@ export const refreshAccessToken = ( input.clientAuth ?? DEFAULT_CLIENT_AUTH_METHOD, ); const scope = scopeParameter(scopes); + assertExtraTokenParamsValid(input.extraTokenParams); const extraParams = new URLSearchParams(); if (scope !== undefined) { extraParams.set("scope", scope); @@ -1482,6 +1532,7 @@ export const refreshAccessToken = ( if (input.resource) { extraParams.set("resource", input.resource); } + appendExtraTokenParams(extraParams, input.extraTokenParams); const additionalParameters = Array.from(extraParams.keys()).length > 0 ? extraParams : undefined; if (input.requestFormat === "json") { @@ -1495,6 +1546,7 @@ export const refreshAccessToken = ( refresh_token: input.refreshToken, ...(scope !== undefined ? { scope } : {}), ...(input.resource ? { resource: input.resource } : {}), + ...(input.extraTokenParams ?? {}), }, timeoutMs: input.timeoutMs, endpointUrlPolicy: input.endpointUrlPolicy, diff --git a/packages/core/sdk/src/oauth-service.ts b/packages/core/sdk/src/oauth-service.ts index f056211040..f149378ec5 100644 --- a/packages/core/sdk/src/oauth-service.ts +++ b/packages/core/sdk/src/oauth-service.ts @@ -52,7 +52,9 @@ import { firstPartyOAuthClientAllowsScopes, firstPartyOAuthClientSlug, isFirstPartyOAuthClientSlug, + parseStoredOAuthTokenParams, parseStoredTokenEndpointAuthMethod, + reservedOAuthTokenParamIn, type ConnectResult, type CreateOAuthClientInput, type EnterpriseManagedStartInput, @@ -571,6 +573,9 @@ interface LoadedOAuthClient { readonly resource: string | null; readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod; readonly tokenRequestFormat?: "form" | "json"; + /** Sent verbatim on every token request this client drives. Omitted for + * rows registered without one. */ + readonly tokenParams?: Readonly>; } /** Provider lifecycle scopes that are required to keep an authorization-code @@ -955,6 +960,13 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { cause: undefined, }); } + const reservedTokenParam = reservedOAuthTokenParamIn(input.tokenParams); + if (reservedTokenParam !== null) { + return yield* new StorageError({ + message: `Token parameter "${reservedTokenParam}" is an OAuth protocol parameter, so a client cannot carry it.`, + cause: undefined, + }); + } const keys = yield* Effect.try({ try: () => deps.ownedKeys(input.owner), catch: (cause) => @@ -1030,6 +1042,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { credential_write: credentialWrite, token_endpoint_auth_method: input.tokenEndpointAuthMethod ?? null, resource: input.resource ?? null, + token_params: input.tokenParams ?? null, origin_kind: input.origin?.kind ?? "manual", // Recorded intent, kept for BOTH origins: a manual app registered from // an integration's dialog stamps its integration so the picker can @@ -1592,6 +1605,18 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }), ); } + // EXPLICIT — this map is sent verbatim on every token request the + // client makes, so a corrupt value must surface loudly rather + // than silently alter what each exchange sends. + const tokenParams = parseStoredOAuthTokenParams(row.token_params); + if (tokenParams === null) { + return Effect.fail( + new StorageError({ + message: `oauth_client ${String(row.slug)} has invalid stored token parameters`, + cause: undefined, + }), + ); + } return Effect.succeed({ owner: String(row.owner) as Owner, slug: OAuthClientSlug.make(String(row.slug)), @@ -1601,6 +1626,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { resource: row.resource == null ? null : String(row.resource), clientId: String(row.client_id), ...(tokenEndpointAuthMethod === undefined ? {} : { tokenEndpointAuthMethod }), + ...(tokenParams === undefined ? {} : { tokenParams }), origin: parseOAuthClientOrigin(row), } satisfies OAuthClientSummary); }), @@ -1655,6 +1681,19 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { }), ); } + // EXPLICIT — these parameters are sent on every token request this + // client drives (the code exchange, the client-credentials mint, and + // refresh), so a corrupt stored value must surface loudly rather + // than silently change what the exchanges send. + const tokenParams = parseStoredOAuthTokenParams(row.token_params); + if (tokenParams === null) { + return Effect.fail( + new StorageError({ + message: `oauth_client ${String(slug)} has invalid stored token parameters`, + cause: undefined, + }), + ); + } // `client_secret_item_id` is null for DCR-minted / public PKCE clients; // the token exchange treats a missing secret as "public client, omit // client_secret" (see pickClientAuth). A confidential client persisted @@ -1687,6 +1726,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { clientSecret, resource: row.resource == null ? null : String(row.resource), ...(tokenEndpointAuthMethod === undefined ? {} : { tokenEndpointAuthMethod }), + ...(tokenParams === undefined ? {} : { tokenParams }), } satisfies LoadedOAuthClient; }); }), @@ -1841,6 +1881,9 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { scopes: requestedScopes, clientAuth: client.tokenEndpointAuthMethod, resource: client.resource ?? undefined, + // The registered app's provider-shaped parameters (Auth0's + // `audience`, for example) travel on this mint, not just refresh. + extraTokenParams: client.tokenParams, endpointUrlPolicy: deps.endpointUrlPolicy, fetch, }).pipe( @@ -2269,6 +2312,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { clientAuth: client.tokenEndpointAuthMethod, requestFormat: client.tokenRequestFormat, resource: client.resource ?? undefined, + extraTokenParams: client.tokenParams, endpointUrlPolicy: deps.endpointUrlPolicy, fetch, }).pipe( diff --git a/packages/core/sdk/src/shared.ts b/packages/core/sdk/src/shared.ts index 391c12e3fa..52af1a0dd5 100644 --- a/packages/core/sdk/src/shared.ts +++ b/packages/core/sdk/src/shared.ts @@ -157,11 +157,15 @@ export { EnterpriseManagedStartInputSchema, EnterpriseIdentityProviderDescriptorSchema, TokenEndpointAuthMethodSchema, + OAuthTokenParamsSchema, + type OAuthTokenParams, type SubjectTokenType, type EnterpriseManagedStartInput, type EnterpriseIdentityProviderDescriptor, type TokenEndpointAuthMethod, isTokenEndpointAuthMethod, + isReservedOAuthTokenParam, + reservedOAuthTokenParamIn, type FirstPartyOAuthClientConfig, type OAuthGrant, type OAuthAuthentication, diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index 222dd0f682..9f064a8344 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -1668,6 +1668,9 @@ function AddAccountModalView(props: AddAccountModalProps) { ...(oauthClientHandoff.resource ? { resource: oauthClientHandoff.resource } : {}), ...(grant ? { grant } : {}), ...(oauthClientHandoff.clientId ? { clientId: oauthClientHandoff.clientId } : {}), + ...(oauthClientHandoff.tokenParams != null + ? { tokenParams: oauthClientHandoff.tokenParams } + : {}), }; }, [oauthClientHandoff]); diff --git a/packages/react/src/components/oauth-client-form.test.ts b/packages/react/src/components/oauth-client-form.test.ts index c0ea6f1ba0..e5c71278d8 100644 --- a/packages/react/src/components/oauth-client-form.test.ts +++ b/packages/react/src/components/oauth-client-form.test.ts @@ -5,9 +5,11 @@ import { Schema } from "effect"; import { canSubmitOAuthClientForm, initialOAuthClientOwner, + parseTokenParamsInput, preferredManualTokenEndpointAuthMethod, registrationScopes, resolveOriginIntegration, + type ParseTokenParamsResult, } from "./oauth-client-form"; import { connectionOwnerOptionsForAccess, @@ -24,6 +26,11 @@ const validBase = { tokenUrl: "https://us.posthog.com/oauth/token", } as const; +/** The failure reason of a token-parameter parse, with an unexpected success + * reading as its own assertion failure below. */ +const failureMessage = (parsed: ParseTokenParamsResult): string => + parsed.ok ? "unexpected: the input parsed" : parsed.message; + const SlackDeepLinkManifest = Schema.Struct({ oauth_config: Schema.Struct({ redirect_urls: Schema.Array(Schema.String), @@ -154,6 +161,40 @@ describe("OAuth client owner default", () => { }); }); +describe("parseTokenParamsInput", () => { + it("parses a JSON map of provider-shaped parameters", () => { + expect(parseTokenParamsInput('{"audience":"https://api.example.com/"}')).toEqual({ + ok: true, + params: { audience: "https://api.example.com/" }, + }); + }); + + it("reports the empty field as no map, exactly like a blank resource", () => { + expect(parseTokenParamsInput("")).toEqual({ ok: true, params: null }); + expect(parseTokenParamsInput(" ")).toEqual({ ok: true, params: null }); + }); + + it("rejects non-JSON input", () => { + expect(failureMessage(parseTokenParamsInput("audience=https://api.example.com/"))).toMatch( + /JSON object/, + ); + }); + + it("rejects a value that is not a flat string map", () => { + expect(failureMessage(parseTokenParamsInput('{"audience":123}'))).toMatch(/JSON object/); + expect(failureMessage(parseTokenParamsInput('["audience"]'))).toMatch(/JSON object/); + }); + + it("rejects names that would shadow OAuth protocol parameters", () => { + expect(failureMessage(parseTokenParamsInput('{"grant_type":"password"}'))).toMatch( + /protocol parameter/, + ); + expect(failureMessage(parseTokenParamsInput('{"client_secret":"sneaky"}'))).toMatch( + /protocol parameter/, + ); + }); +}); + describe("preferredManualTokenEndpointAuthMethod", () => { it("selects Basic when it is the only advertised confidential method", () => { expect(preferredManualTokenEndpointAuthMethod(["client_secret_basic"])).toBe("basic"); diff --git a/packages/react/src/components/oauth-client-form.tsx b/packages/react/src/components/oauth-client-form.tsx index d6e50b6157..01bf314104 100644 --- a/packages/react/src/components/oauth-client-form.tsx +++ b/packages/react/src/components/oauth-client-form.tsx @@ -1,8 +1,10 @@ import { useMemo, useState } from "react"; import { useAtomSet } from "@effect/atom-react"; import * as Exit from "effect/Exit"; +import { Option, Schema } from "effect"; import { ExternalLink } from "lucide-react"; import { + isReservedOAuthTokenParam, isTokenEndpointAuthMethod, OAuthClientSlug, type IntegrationSlug, @@ -72,6 +74,10 @@ export interface OAuthClientFormPrefill { readonly tokenEndpointAuthMethodsSupported?: readonly string[]; /** Saved manual-client transport. Omitted means client_secret_post. */ readonly tokenEndpointAuthMethod?: TokenEndpointAuthMethod; + /** Provider-shaped, NON-secret token-request parameters pre-filled by an + * `oauth.clients.createHandoff` deep link (Auth0's `audience`, for + * example). Sent on every token request the registered app makes. */ + readonly tokenParams?: Readonly>; } export const preferredManualTokenEndpointAuthMethod = ( @@ -90,6 +96,60 @@ export const registrationScopes = ( discoveredScopes: readonly string[], ): readonly string[] => (declaredScopes.length > 0 ? declaredScopes : discoveredScopes); +/** Extra token parameters arrive at the form as one-line JSON + * (`{"audience":"https://…/api/v2/"}`). Decoded with Effect Schema so a + * malformed or non-string-valued field reads as a decode failure rather than a + * thrown parse error — registration is client-side input handling, not an + * Effect workflow. */ +const OAuthTokenParamsJson = Schema.fromJsonString(Schema.Record(Schema.String, Schema.String)); +// Compiled once at module scope — the decode runs on every form submit and +// every deep-link parse. +const decodeOAuthTokenParamsJson = Schema.decodeUnknownOption(OAuthTokenParamsJson); + +/** Decode a handoff URL's serialized token-parameter map. `undefined` when the + * value is missing or malformed: the handoff URL is public request data, so a + * corrupted field drops just itself instead of blocking the registration. */ +export const decodeOAuthTokenParams = ( + raw: string | null | undefined, +): Record | undefined => { + if (raw == null) return undefined; + const decoded = decodeOAuthTokenParamsJson(raw); + if (Option.isNone(decoded)) return undefined; + return Object.fromEntries(Object.entries(decoded.value)); +}; + +export type ParseTokenParamsResult = + | { readonly ok: true; readonly params: Record | null } + | { readonly ok: false; readonly message: string }; + +/** Parse the extra token-parameter JSON input into the map the client stores, + * with the failure reason per problem: not JSON, not a flat string map, or a + * name that would shadow one of the grant protocol's own parameters. + * `params: null` means the field is empty — omit the map entirely, exactly + * like a resource field left blank. */ +export const parseTokenParamsInput = (text: string): ParseTokenParamsResult => { + const trimmed = text.trim(); + if (trimmed.length === 0) return { ok: true, params: null }; + const decoded = decodeOAuthTokenParamsJson(trimmed); + if (Option.isNone(decoded)) { + return { + ok: false, + message: + 'Extra token parameters must be a JSON object whose values are strings, like {"audience":"…"}', + }; + } + const entries = Object.entries(decoded.value); + for (const [name] of entries) { + if (isReservedOAuthTokenParam(name)) { + return { + ok: false, + message: `"${name}" is an OAuth protocol parameter, so it cannot be sent as a token parameter`, + }; + } + } + return { ok: true, params: Object.fromEntries(entries) }; +}; + /** The `originIntegration` to send with a `createClient` payload. When the * caller passes an explicit `intentIntegration` (edit flow: the app's * preserved recorded intent, which may legitimately be `null`), it wins @@ -218,6 +278,13 @@ export function OAuthClientForm(props: { const [authorizationUrl, setAuthorizationUrl] = useState(prefill?.authorizationUrl ?? ""); const [tokenUrl, setTokenUrl] = useState(prefill?.tokenUrl ?? ""); const [resource, setResource] = useState(prefill?.resource ?? null); + // Extra token-request parameters entered as a single-line JSON map + // (`{"audience":"https://…/api/v2/"}`), for providers such as Auth0 whose + // client-credentials grant only issues a token when it names the API. + // Parsed at submit; a malformed or protocol-shadowing entry fails there. + const [tokenParamsText, setTokenParamsText] = useState(() => + prefill?.tokenParams !== undefined ? JSON.stringify(prefill.tokenParams) : "", + ); // Scopes to register with DCR. The form has no scopes input (DCR sends them // verbatim). Declared scopes are the integration's TEMPLATE scopes — immutable // and authoritative, so they survive Discover. Discovered scopes are seeded @@ -365,6 +432,12 @@ export function OAuthClientForm(props: { const handleSubmit = async () => { if (!canSubmit) return; + const parsed = parseTokenParamsInput(tokenParamsText); + if (!parsed.ok) { + toast.error(parsed.message); + return; + } + const tokenParams = parsed.params; setSubmitting(true); const slug = fixedSlug ?? uniqueClientSlug(name, existingSlugs); const exit = await doCreate({ @@ -378,6 +451,7 @@ export function OAuthClientForm(props: { clientSecret: clientSecret.trim(), tokenEndpointAuthMethod, resource: normalizedResource, + ...(tokenParams === null ? {} : { tokenParams }), // Editing preserves the app's already-recorded origin (via // `intentIntegration`, passed verbatim by the caller); a fresh // registration from an integration's dialog stamps recorded intent. @@ -723,6 +797,27 @@ export function OAuthClientForm(props: { /> + {/* Provider-shaped extra token-request parameters (Auth0's + `audience`, for example) — one-line JSON map, NON-secret. The + grant protocol's own parameter names are refused at submit. */} +
+ + ) => + setTokenParamsText(e.target.value) + } + className="font-mono" + /> +
+ {endpointsKnown ? (